Compile selected plugins into the svc deploy binary #558

Merged
weichen merged 13 commits from feature/svc-plugin-compose into main 2026-10-15 03:58:43 +00:00
9 changed files with 1268 additions and 26 deletions
File diff suppressed because one or more lines are too long
+2
View File
@@ -12,6 +12,8 @@
目录中的既有文档不因被收录而自动成为已批准规划。记录、任务关联及收尾建议按
[交付规范的长期方向与任务衔接](policy/delivery.md#长期方向与任务衔接) 执行。
- [svc 插件组合部署(--with)方案 r1](modules/documents/docs/proposals/2026-10-14-svc-plugin-compose-deploy.md):待评审。部署时把登记插件与 Dev 基座编成一个二进制并校验激活。收录不构成批准。
- [用户、职位角色与分层权限统一方案 r1](modules/documents/docs/proposals/2026-10-13-identity-role-permission-plan.md):用户已批准,首批仅 I1(U1–U2);
[权限合同](modules/documents/docs/proposals/2026-10-13-iam-i1-permission-contract.md) 冻结边界,
[I1 实施候选与评审记录](modules/documents/docs/proposals/2026-10-13-iam-i1-review.md) 维护实际结果与剩余限制;后续单元须另行授权。
+21
View File
@@ -381,6 +381,27 @@ scripts/svc.sh <name> deploy --dry-run # 只打印计划
`commits` 表格:dev 源标注 `[最新]`(源仓 origin/main 顶)与 `[已部署:<服务名>]`
(从各服务 `svc.env source=` 汇总);release 源额外给出版本号与对应 Dev 提交。
### 5.6 编译插件组合(`--with`)
`plugins/registry.json` 登记可以编进基座的插件。`deploy --with` 在本次 `--from local`
或 `--from dev` 的基座上生成空白导入并一次 `go build -mod=mod`,产物仍是
`agent-system`。插件不自带基座 pin;对不上就构建失败。
```bash
scripts/svc.sh dev deploy --from dev@<sha> --with rdm --dry-run
scripts/svc.sh dev deploy --from local --with rdm@<sha>
scripts/svc.sh dev deploy --with none --allow-source-switch
```
- 终端上没给 `--with` 时按编号勾选,回车沿用上次;非交互沿用 `svc.env` 的 `plugins=`,没有则不编入。
- `--with` 与 `--from rdm`、`--from release` 互斥。旧 `--from rdm` 仍用 RDM 仓自己的 `pins/` 构建。
- 部署后核对 `/api/plugins`、`/api/registry` 的 `compiled` 和 `/api/commands`。未启用且认证开启时,用临时管理员 key 启用。失败走 release 回退。
- `source` 写成 `dev@<基座短提交>+rdm@<插件短提交>`。`plugins=` 记 `id@完整提交`。`update` 原样带上,不再询问。
- 已经在跑的服务要改插件 id 集合,必须加 `--allow-source-switch`。第一次部署不需要。
- 登记了 `web` 的插件最多一个,复用伴生 `<name>web`。
`SVC_COMPOSE_STUB=1` 只给测试跳过真实 `go build`。真实组合构建与 `make test-lifecycle` 不在桩测试里。
## 6. 命令参考:全局命令
### 6.1 list / status(总览)
+2
View File
@@ -1,3 +1,5 @@
# plugins/
示例插件与插件开发参考(P4 起填充;插件开发指南见 modules/documents/docs/guides/)。
`registry.json` 是 `svc.sh deploy --with` 的编译插件登记表。Dev 仓只保存登记,插件源码在各自仓库。说明见 `docs/svc.md` 与 `scripts/svc.sh help plugins`。
+15
View File
@@ -0,0 +1,15 @@
{
"plugins": [
{
"id": "rdm",
"name": "研发记忆 RDM",
"repo": "ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-RDM.git",
"repoEnv": "SVC_RDM_ORIGIN",
"defaultRef": "origin/main",
"goModule": "agent-system-rdm",
"entryPackage": "agent-system-rdm/plugins/rdm",
"commandPrefix": "rdm.",
"web": "web"
}
]
}
+636 -24
View File
@@ -41,6 +41,9 @@
# --from rdm[@<sha>] 下游组合仓 Agent-System-RDM(独立 RDM 源仓物化 pins/ 后
# make build 出 agent-system-rdm 组合二进制;web/ 工作台静态
# 资产随环境落 htdocs/,供伴生 static 服务 --dir 使用)
# --with id[,id…] 把登记表里的插件与基座编成一个二进制;id@<sha> 钉提交
# --with none 明确不编入插件。无 --with 且在终端上时交互勾选;
# 非交互则沿用上次 plugins=。与 --from rdm/release 互斥
# --pick [dev|release|rdm] 交互式编号选提交;--dry-run 只打印部署计划
#
# ════════════════════ 常用参数(服务级)════════════════════
@@ -164,7 +167,7 @@ svc.sh —— 本机服务管理:后端(agent) / 前端(static) / 数据库(po
常用:
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> deploy [--from dev@<sha>] [--with id] 部署并启动(可把插件编进二进制)
scripts/svc.sh <name> start|stop|restart|logs [N] 生命周期与日志
scripts/svc.sh <name> status 服务详情(版本/来源/DSN 等)
scripts/svc.sh <name> db [pg名|--clear] agent 数据库绑定/切换(缺省查看)
@@ -184,13 +187,13 @@ svc.sh —— 本机服务管理:后端(agent) / 前端(static) / 数据库(po
scripts/svc.sh <name> rdm-admin [show] RDM 域管理员台账查看(含基座用户核验)
scripts/svc.sh <name> rdm-admin transfer --to <id> RDM 域管理员受信移交(锁死恢复通道)
帮助:--help 完整帮助 | help [主题] 分主题(model deploy lifecycle accounts db dbops config recovery examples safety)
帮助:--help 完整帮助 | help [主题] 分主题(model deploy plugins lifecycle accounts db dbops config recovery examples safety)
文档:docs/svc.md
EOF
}
# HELP_TOPICS:help 的主题清单(cmd_help 与提示共用)。
HELP_TOPICS="model deploy lifecycle accounts rdm-admin db dbops config recovery examples safety"
HELP_TOPICS="model deploy plugins lifecycle accounts rdm-admin db dbops config recovery examples safety"
cmd_help() {
local topic="${1:-}"
@@ -201,6 +204,7 @@ svc.sh 分主题帮助(scripts/svc.sh help <主题>;完整 --help;文档 d
model 服务模型:命名、目录布局、svc.env 字段、history 事件、pidcheck
deploy 部署:三来源(local/dev@sha/release@v)、--pick、--dry-run、static --dir
plugins 编译插件:--with、交互勾选、登记表、激活与换集合门禁
lifecycle 生命周期:start/stop/restart/logs/status、set 变更、rm 注销
accounts 账户 CLI:create/list/reset-password/issue-login-key/revoke-login-key/bootstrap-admin
rdm-admin RDM 域管理员台账:查看 / 受信移交(锁死恢复通道)
@@ -247,7 +251,9 @@ svc.env 字段:
addr 监听地址 host:port(持久化——下次 start 复用)
tags 标签(逗号分隔)
source 部署来源(local@<sha> | dev@<sha> | release@<版本> | dir@<路径> |
postgres@local | clone@<名>)
postgres@local | clone@<名>)。编入插件时在基座标签后追加
+<id>@<短提交>(如 dev@abc+rdm@def)
plugins 本次编入的编译插件(id@完整提交,逗号分隔)。update 原样复用,不再询问
version 产物版本串(agent-system version 输出)
started 最近启动时间
pidcheck 进程判定标记(agent=二进制路径、postgres=pgdata 路径、static=--directory)
@@ -307,10 +313,15 @@ EOF
htdocs/,并自动部署伴生 <name>web(--kind static --dir)。
注意:rdm 源不支持 --mock 与 --config;模型设置播种
(--model-*/--tavily-keys)仅基座产物支持。
--with id[,id…] 把 plugins/registry.json 里的插件与本次基座编成一个二进制。
id@<sha> 钉住该插件提交;--with none 明确不编入。
终端上没给 --with 时交互勾选(回车沿用上次,none 清空);
非交互则沿用 svc.env 的 plugins=。详见 help plugins。
--pick [dev|release|rdm] 交互式编号选择(需 TTY;列表与提示走 stderr)
--dry-run 只打印部署计划不执行
--allow-source-switch 同名服务换源种类(dev↔rdm↔release↔local)须显式确认;
同种类换 sha(含 update)不需要
--allow-source-switch 同名服务换源种类(dev↔rdm↔release↔local),或已有服务的
插件 id 集合发生变化时须显式确认。同种类只换 sha、首次部署、
update 沿用已记录的 plugins= 都不需要
行为细节:
- deploy 前自动停止旧实例(只停自己 pidfile+cmdline 校验的进程)
@@ -330,6 +341,43 @@ EOF
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
scripts/svc.sh dev deploy --from local --with rdm
EOF
return 0 ;;
plugins)
cat >&2 <<'EOF'
【plugins】把登记插件与基座编成一个二进制
登记表:plugins/registry.json(Dev 仓只保存登记,不自带插件源码,也不自带基座 pin)。
每条至少有 id、name、repo、goModule、entryPackage。可选 repoEnv(环境变量覆盖
克隆地址,如 RDM 的 SVC_RDM_ORIGIN)、defaultRef、commandPrefix、web(静态工作台
相对目录)。第一版最多一个带 web 的插件。
选择:
--with rdm,foo 按 id 选择,提交用登记表的 defaultRef
--with rdm@<sha> 钉住该插件的提交
--with none 明确不编入
终端且未给 --with 编号勾选。回车沿用 svc.env 的 plugins=,none 清空,首次默认不选
非交互且未给 --with 沿用 plugins=;没有则不编入(现有脚本与 CI 行为不变)
SVC_FORCE_PLUGIN_PROMPT=1 无终端时也走勾选(测试用)
构建(仅 --from local 或 --from dev;与 --from rdm / --from release 互斥):
在隔离副本里生成 cmd/agent-system/zz_plugins_gen.go(空白导入 entryPackage),
go mod edit 增加 require 与 replace,再 CGO_ENABLED=0 go build -mod=mod。
产物仍叫 agent-system,不改主检出的 go.mod。对不上基座就构建失败并留下原因。
旧 --from rdm 仍用 RDM 仓自己的 pins/ 构建,不走本路径。
部署后:
用 /api/plugins、/api/registry 的 compiled 和 /api/commands 确认每个所选插件已注册
且命令面启用。被禁用时,认证开启则用临时管理员 key 显式启用。任一失败走 release 回退。
svc.env 与 release 清单记下 plugins=。source 形如 dev@<基座短提交>+rdm@<插件短提交>。
已有服务要改插件 id 集合,必须加 --allow-source-switch。update / recover 复用记录,不再询问。
带 web 的插件复用伴生 <name>web(最多一个)。
示例:
scripts/svc.sh dev deploy --from dev@73b2802 --with rdm --dry-run
scripts/svc.sh dev deploy --from local --with rdm@<sha>
scripts/svc.sh dev deploy --with none --allow-source-switch
EOF
return 0 ;;
lifecycle)
@@ -684,7 +732,7 @@ 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_FROM=""; CLI_PICK=""; CLI_DRYRUN=0; CLI_WITH=""; CLI_WITH_SET=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
@@ -714,6 +762,7 @@ parse_args() {
--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 ;;
--with) need_value "$@"; CLI_WITH="$2"; CLI_WITH_SET=1; 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 ;;
@@ -851,7 +900,7 @@ svc_merge_tags() {
# 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
local pid prev_src prev_dbref prev_conffile prev_db prev_dsn prev_plugins plugins_out merged_tags
pid="$(read_pid)"
prev_src=""
[ -f "$ENV_DIR/svc.env" ] && prev_src="$(svc_get source)"
@@ -859,6 +908,13 @@ record_state() {
prev_conffile="$(svc_get conffile)"
prev_db="$(svc_get db)"
prev_dsn="$(svc_get dsn)"
prev_plugins="$(svc_get plugins)"
plugins_out="$prev_plugins"
# 只有激活校验和 UI 探活都通过之后才提交新的 plugins=。中途 record_state
# (含回退拉起旧制品)继续沿用上一份,避免旧二进制被登记成新插件集合。
if [ "${COMPOSE_PLUGINS_COMMIT:-0}" = 1 ]; then
plugins_out="${COMPOSE_PLUGINS_KV:-}"
fi
SVC_SOURCE="${SRC_LABEL:-${prev_src:-}}"
merged_tags="$(svc_merge_tags)"
local db_out="" dbref_out="" dsn_out=""
@@ -896,6 +952,7 @@ record_state() {
[ -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"
[ -n "$plugins_out" ] && printf 'plugins=%s\n' "$plugins_out"
[ $# -gt 0 ] && printf '%s\n' "$@"
} > "$tmp"
chmod 600 "$tmp"
@@ -2051,8 +2108,8 @@ svc_release_stage() {
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 '{"id":"%s","source":"%s","created":"%s","plugins":"%s"}\n' \
"$id" "${SRC_LABEL:-}" "$(date -u +%FT%TZ)" "${COMPOSE_PLUGINS_KV:-}" > "$dir/release.json"
printf '%s' "$dir"
}
@@ -2240,6 +2297,11 @@ svc_deploy_journal_resolve() {
# svc_deploy_recycle_wt:deploy 各失败/成功出口统一回收构建 worktree(尽力而为)。
svc_deploy_recycle_wt() {
compose_recycle || true
if [ -n "${COMPOSE_LOCAL_DIR:-}" ]; then
rm -rf "$COMPOSE_LOCAL_DIR"
COMPOSE_LOCAL_DIR=""
fi
case "${SRC_KINDSRC:-}" in
dev) [ -n "${wt:-}" ] && recycle_worktree "$SRC_SHA" ;;
rdm) [ -n "${wt:-}" ] && recycle_worktree "rdm-$SRC_SHA" "$RDM_SRC_DIR" ;;
@@ -2535,6 +2597,436 @@ agent_do_rdm_admin() {
return 0
}
# ---- 编译插件组合(--with)----
# 登记表 plugins/registry.json。选中的插件与本次 Dev 基座编成一个 agent-system。
# plugins= 记 id@完整提交;source 标签追加 +id@短提交。SVC_COMPOSE_STUB=1 只给测试跳过 go build。
plugin_ids_from_kv() {
python3 -c 'import sys
raw = sys.argv[1].strip()
ids = []
for part in raw.split(","):
part = part.strip()
if not part or part == "none":
continue
ids.append(part.split("@", 1)[0])
print(",".join(sorted(set(ids))))' "${1:-}"
}
plugin_registry_text() {
case "${SRC_KINDSRC:-local}" in
dev)
git -C "$SRC_DIR" show "$SRC_FULL:plugins/registry.json" 2>/dev/null || true
;;
*)
if [ -f "$ROOT/plugins/registry.json" ]; then
cat "$ROOT/plugins/registry.json"
fi
;;
esac
}
plugin_cache_root() {
printf '%s' "${SVC_PLUGIN_SRC_ROOT:-${XDG_CACHE_HOME:-$HOME/.cache}/agent-system-svc}"
}
# plugin_src_ready ID URL → stdout 源仓路径。
plugin_src_ready() {
local id="$1" url="$2" dir
dir="$(plugin_cache_root)/plugin-$id"
if [ ! -d "$dir/.git" ]; then
c_info "[$ENV_NAME] 初始化插件源仓 $id:$dir"
mkdir -p "$(plugin_cache_root)"
git clone --quiet "$url" "$dir" >&2 || { c_err "[$ENV_NAME] 插件 $id 克隆失败:$url"; return 1; }
fi
if [ "${CLI_NOFETCH:-0}" != 1 ]; then
git -C "$dir" fetch origin --quiet >&2 || c_warn "[$ENV_NAME] 插件 $id fetch 失败——使用本地缓存($dir)"
fi
printf '%s' "$dir"
}
# resolve_plugins:根据 --with / 交互 / 上次 plugins= 填 COMPOSE_*。
# 不物化 worktree(dry-run 也要解析提交)。失败 return 1。
resolve_plugins() {
COMPOSE_PLUGINS_SET=0
COMPOSE_PLUGINS_KV=""
COMPOSE_LABEL_SUFFIX=""
COMPOSE_WEB_ID=""
COMPOSE_WEB_REL=""
COMPOSE_SPEC=""
: > "${COMPOSE_TSV:-/dev/null}" 2>/dev/null || true
COMPOSE_TSV=""
case "${SRC_KINDSRC:-}" in
release|rdm)
if [ "${CLI_WITH_SET:-0}" = 1 ] && [ "$CLI_WITH" != none ]; then
c_err "[$ENV_NAME] --from $SRC_KINDSRC 不能与 --with 一起用(release 是成品;rdm 源用组合仓自己的 pins。要选插件请用 --from dev 或 --from local)"
return 1
fi
if [ "${CLI_WITH:-}" = none ]; then
COMPOSE_PLUGINS_SET=1
COMPOSE_PLUGINS_KV=""
fi
return 0
;;
esac
local reg prev choice mode="reuse"
prev="$(svc_get plugins)"
if [ "${CLI_WITH_SET:-0}" = 1 ]; then
mode="explicit"
choice="$CLI_WITH"
elif [ -t 0 ] || [ "${SVC_FORCE_PLUGIN_PROMPT:-}" = 1 ]; then
mode="prompt"
else
choice="$prev"
fi
reg="$(plugin_registry_text)"
if [ "$mode" = prompt ]; then
if [ -z "$reg" ]; then
c_info "[$ENV_NAME] 没有插件登记表,本次不编入插件"
choice="none"
else
python3 -c 'import json,sys
reg=json.loads(sys.argv[1] or "{\"plugins\":[]}")
prev=set()
for part in (sys.argv[2] or "").split(","):
part=part.strip()
if part and part!="none":
prev.add(part.split("@",1)[0])
plugins=reg.get("plugins") or []
if not plugins:
print("(登记表是空的)", file=sys.stderr)
sys.exit(0)
print("可选编译插件(* 为上次选择;回车保持,none 清空,序号用逗号分隔):", file=sys.stderr)
for i,p in enumerate(plugins,1):
mark="*" if p.get("id") in prev else " "
print(" %d) [%s] %s %s" % (i, mark, p.get("id"), p.get("name","")), file=sys.stderr)
' "$reg" "$prev" >&2
local sel
printf '选择:' >&2
if ! read -r sel; then
printf '\n' >&2
c_info "[$ENV_NAME] 已取消插件选择"
return 1
fi
choice="$sel"
[ -z "$choice" ] && choice="$prev"
[ -z "$choice" ] && choice="none"
fi
fi
[ "$choice" = none ] && choice=""
if [ -z "$choice" ]; then
COMPOSE_PLUGINS_SET=1
COMPOSE_PLUGINS_KV=""
return 0
fi
if [ -z "$reg" ]; then
c_err "[$ENV_NAME] 未找到 plugins/registry.json,无法解析 --with $choice"
return 1
fi
local parsed
parsed="$(python3 -c 'import json,sys
reg=json.loads(sys.argv[1])
choice=sys.argv[2].strip()
by={p["id"]: p for p in reg.get("plugins") or []}
items=[]
if choice.isdigit() or all(part.strip().isdigit() for part in choice.replace(" ",",").split(",") if part.strip()):
plugins=reg.get("plugins") or []
for part in choice.replace(" ",",").split(","):
part=part.strip()
if not part:
continue
n=int(part)
if n<1 or n>len(plugins):
sys.stderr.write("序号超范围:%s\n"%part)
sys.exit(1)
p=plugins[n-1]
items.append((p, ""))
else:
for part in choice.split(","):
part=part.strip()
if not part or part=="none":
continue
pid, _, ref = part.partition("@")
p=by.get(pid)
if not p:
sys.stderr.write("登记表没有插件:%s\n"%pid)
sys.exit(1)
items.append((p, ref))
seen=set(); webs=[]
for p, ref in items:
if p["id"] in seen:
continue
seen.add(p["id"])
web=(p.get("web") or "").strip()
if web:
webs.append(p["id"])
fields=[p["id"], p.get("repo",""), p.get("repoEnv",""), p.get("defaultRef","origin/main"), ref, p.get("goModule",""), p.get("entryPackage",""), p.get("commandPrefix",""), web]
if any("\t" in str(f) or "\n" in str(f) for f in fields):
sys.stderr.write("插件登记含非法字符:%s\n"%p["id"])
sys.exit(1)
if not p.get("goModule") or not p.get("entryPackage") or not p.get("repo"):
sys.stderr.write("插件登记缺少 repo/goModule/entryPackage:%s\n"%p["id"])
sys.exit(1)
print("\x1f".join(fields))
if len(webs)>1:
sys.stderr.write("第一版只允许一个带 web 工作台的插件:%s\n"%",".join(webs))
sys.exit(1)
' "$reg" "$choice")" || { c_err "[$ENV_NAME] 插件选择无效"; return 1; }
[ -n "$parsed" ] || { COMPOSE_PLUGINS_SET=1; return 0; }
local tsv id repo repo_env def_ref req_ref module entry prefix web url dir full short
tsv="$(mktemp)"
COMPOSE_TSV="$tsv"
local kv="" suffix="" spec="" web_id="" web_rel=""
# 分隔符用 Unit Separator:空的请求提交不能用 Tab,bash read 会把连续空白并成一个分隔。
while IFS=$'\x1f' read -r id repo repo_env def_ref req_ref module entry prefix web; do
[ -n "$id" ] || continue
url="$repo"
if [ -n "$repo_env" ] && [ -n "${!repo_env:-}" ]; then
url="${!repo_env}"
fi
dir="$(plugin_src_ready "$id" "$url")" || return 1
local ref="${req_ref:-$def_ref}"
full="$(git -C "$dir" rev-parse --verify --quiet "${ref}^{commit}")" || {
c_err "[$ENV_NAME] 插件 $id 无此提交:$ref"
return 1
}
short="$(git -C "$dir" rev-parse --short=12 "$full")"
kv="${kv:+$kv,}$id@$full"
suffix="${suffix}+$id@$short"
spec="${spec:+$spec,}$id:$prefix"
if [ -n "$web" ]; then
web_id="$id"
web_rel="$web"
fi
printf '%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\x1f%s\n' "$id" "$dir" "$full" "$module" "$entry" "$prefix" "$web" >> "$tsv"
done <<< "$parsed"
COMPOSE_PLUGINS_SET=1
COMPOSE_PLUGINS_KV="$kv"
COMPOSE_LABEL_SUFFIX="$suffix"
COMPOSE_WEB_ID="$web_id"
COMPOSE_WEB_REL="$web_rel"
COMPOSE_SPEC="$spec"
return 0
}
compose_require_switch() {
local prev_ids new_ids prev_src
prev_src="$(svc_get source)"
prev_ids="$(plugin_ids_from_kv "$(svc_get plugins)")"
new_ids="$(plugin_ids_from_kv "${COMPOSE_PLUGINS_KV:-}")"
[ "$prev_ids" = "$new_ids" ] && return 0
# 第一次部署没有旧来源、也没有旧插件集合,选定插件不是“换集合”。
if [ -z "$prev_src" ] && [ -z "$prev_ids" ]; then
return 0
fi
if [ "${CLI_ALLOW_SOURCE_SWITCH:-0}" = 1 ]; then
c_warn "[$ENV_NAME] 插件集合已确认:${prev_ids:-无} → ${new_ids:-无}(--allow-source-switch)"
return 0
fi
c_err "[$ENV_NAME] 插件集合变化 ${prev_ids:-无} → ${new_ids:-无} 需显式确认"
c_err " 加 --allow-source-switch 后重试"
return 1
}
# compose_materialize_plugins → 填充 COMPOSE_READY_TSV(module, entry, abs path, web abs)
compose_materialize_plugins() {
[ -n "${COMPOSE_TSV:-}" ] && [ -s "$COMPOSE_TSV" ] || return 0
COMPOSE_READY_TSV="$(mktemp)"
local id dir full module entry prefix web sha wt
while IFS=$'\x1f' read -r id dir full module entry prefix web; do
[ -n "$id" ] || continue
sha="$(git -C "$dir" rev-parse --short=12 "$full")"
wt="$RUNTIME/.worktrees/plugin-${id}-${sha}"
if [ ! -d "$wt" ]; then
c_info "[$ENV_NAME] 物化插件 $id@$sha"
git -C "$dir" worktree add --detach "$wt" "$full" >/dev/null || return 1
fi
printf '%s\x1f%s\x1f%s\x1f%s\n' "$module" "$entry" "$wt" "$web" >> "$COMPOSE_READY_TSV"
if [ -n "$web" ]; then
COMPOSE_HTDOCS="$wt/$web"
fi
done < "$COMPOSE_TSV"
}
compose_write_entry() { # BACKEND_DIR
local be="$1" gen="$1/cmd/agent-system/zz_plugins_gen.go" line module entry wt web
{
printf '%s\n' '// Code generated by svc.sh --with. DO NOT EDIT.' 'package main' '' 'import ('
while IFS=$'\x1f' read -r module entry wt web; do
printf '\t_ "%s"\n' "$entry"
done < "$COMPOSE_READY_TSV"
printf '%s\n' ')'
} > "$gen"
while IFS=$'\x1f' read -r module entry wt web; do
( cd "$be" && go mod edit -require="${module}@v0.0.0" -replace="${module}=${wt}" ) || return 1
done < "$COMPOSE_READY_TSV"
}
compose_go_build() { # BACKEND_DIR OUT
local be="$1" out="$2"
c_info "[$ENV_NAME] 组合构建(go build -mod=mod,插件与基座同一二进制)"
( cd "$be" && CGO_ENABLED=0 go build -mod=mod -trimpath -o "$out" ./cmd/agent-system ) || return 1
[ -x "$out" ] || { c_err "[$ENV_NAME] 组合产物缺失:$out"; return 1; }
}
# compose_build_into BACKEND_DIR OUT → 0,并可能设置 COMPOSE_HTDOCS。
compose_build_into() {
local be="$1" out="$2"
if [ "${SVC_COMPOSE_STUB:-}" = 1 ]; then
return 0
fi
compose_materialize_plugins || return 1
[ -s "${COMPOSE_READY_TSV:-/dev/null}" ] || { c_err "[$ENV_NAME] 没有可编译的插件"; return 1; }
compose_write_entry "$be" || return 1
compose_go_build "$be" "$out" || return 1
}
compose_recycle() {
local id dir full module entry prefix web sha
[ -n "${COMPOSE_TSV:-}" ] && [ -f "$COMPOSE_TSV" ] || return 0
while IFS=$'\x1f' read -r id dir full module entry prefix web; do
[ -n "$id" ] || continue
sha="$(git -C "$dir" rev-parse --short=12 "$full" 2>/dev/null || true)"
[ -n "$sha" ] || continue
recycle_worktree "plugin-${id}-${sha}" "$dir"
done < "$COMPOSE_TSV"
rm -f "$COMPOSE_TSV" "${COMPOSE_READY_TSV:-}"
COMPOSE_TSV=""
COMPOSE_READY_TSV=""
}
# assert_plugins_active:所选插件已出现在 /api/plugins 或 /api/registry,且命令面启用。
assert_plugins_active() {
[ -n "${COMPOSE_PLUGINS_KV:-}" ] || return 0
local probe host port url code plugins_body commands_body registry_body hdr=()
probe="${ENV_ADDR}"
host="${probe%:*}"; port="${probe##*:}"
[ "$host" = "0.0.0.0" ] || [ "$host" = "::" ] && host="127.0.0.1"
url="http://$host:$port"
plugins_body="$(mktemp)"; commands_body="$(mktemp)"; registry_body="$(mktemp)"
code="$(curl -s -m 5 -o "$plugins_body" -w '%{http_code}' "$url/api/plugins" || true)"
if [ "$code" = 401 ] || [ "$code" = 403 ]; then
local seed_key="" seed_owner="${CLI_SEED_OWNER:-${SVC_SEED_OWNER:-}}" 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' 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-plugin-activate 2>/dev/null || true)"
[ -n "$seed_key" ] && hdr=(-H "Authorization: Bearer $seed_key")
fi
fi
if [ -z "$seed_key" ]; then
c_err "[$ENV_NAME] 插件激活校验被拒绝(HTTP $code),且无法铸临时管理员 key"
rm -f "$plugins_body" "$commands_body" "$registry_body"
return 1
fi
code="$(curl -s -m 5 -o "$plugins_body" -w '%{http_code}' ${hdr[@]+"${hdr[@]}"} "$url/api/plugins" || true)"
fi
curl -s -m 5 -o "$commands_body" ${hdr[@]+"${hdr[@]}"} "$url/api/commands" || true
curl -s -m 5 -o "$registry_body" ${hdr[@]+"${hdr[@]}"} "$url/api/registry" || true
local report
report="$(python3 - "$plugins_body" "$commands_body" "$registry_body" "${COMPOSE_SPEC:-}" <<'PY'
import json, sys
def load(path):
try:
return json.load(open(path))
except Exception:
return None
plugins, commands, registry, spec = load(sys.argv[1]), load(sys.argv[2]), load(sys.argv[3]), sys.argv[4]
ids=set()
if isinstance(plugins, list):
for p in plugins:
mid=((p or {}).get("manifest") or {}).get("id")
if mid:
ids.add(mid)
compiled=set()
if isinstance(registry, dict):
compiled=set(registry.get("compiled") or [])
if not isinstance(commands, list):
commands=[]
missing=[]; disabled=[]
for item in filter(None, spec.split(",")):
pid, _, prefix = item.partition(":")
if pid not in ids and pid not in compiled:
missing.append(pid)
continue
if not prefix:
continue
matched=[c for c in commands if str(c.get("id","")).startswith(prefix)]
if not matched or not any(c.get("enabled", True) for c in matched):
disabled.append(pid)
print("missing=" + ",".join(missing))
print("disabled=" + ",".join(disabled))
PY
)" || report="missing=parse"
local missing disabled pid
missing="$(printf '%s\n' "$report" | sed -n 's/^missing=//p')"
disabled="$(printf '%s\n' "$report" | sed -n 's/^disabled=//p')"
if [ -n "$disabled" ]; then
local IFS=','
for pid in $disabled; do
curl -s -m 5 -o /dev/null -X POST "$url/api/plugins/$pid/enabled" \
-H 'Content-Type: application/json' ${hdr[@]+"${hdr[@]}"} \
-d '{"enabled":true}' || true
done
unset IFS
curl -s -m 5 -o "$commands_body" ${hdr[@]+"${hdr[@]}"} "$url/api/commands" || true
report="$(python3 - "$plugins_body" "$commands_body" "$registry_body" "${COMPOSE_SPEC:-}" <<'PY'
import json, sys
def load(path):
try:
return json.load(open(path))
except Exception:
return None
plugins, commands, registry, spec = load(sys.argv[1]), load(sys.argv[2]), load(sys.argv[3]), sys.argv[4]
ids=set()
if isinstance(plugins, list):
for p in plugins:
mid=((p or {}).get("manifest") or {}).get("id")
if mid:
ids.add(mid)
compiled=set((registry or {}).get("compiled") or []) if isinstance(registry, dict) else set()
if not isinstance(commands, list):
commands=[]
missing=[]; disabled=[]
for item in filter(None, spec.split(",")):
pid, _, prefix = item.partition(":")
if pid not in ids and pid not in compiled:
missing.append(pid); continue
if not prefix:
continue
matched=[c for c in commands if str(c.get("id","")).startswith(prefix)]
if not matched or not any(c.get("enabled", True) for c in matched):
disabled.append(pid)
print("missing=" + ",".join(missing))
print("disabled=" + ",".join(disabled))
PY
)" || true
missing="$(printf '%s\n' "$report" | sed -n 's/^missing=//p')"
disabled="$(printf '%s\n' "$report" | sed -n 's/^disabled=//p')"
fi
if [ -n "${seed_key:-}" ]; then
"$ENV_BIN" apikey revoke ${seed_dbf[@]+"${seed_dbf[@]}"} --owner "$seed_owner" --name svc-plugin-activate >/dev/null 2>&1 || \
c_warn "[$ENV_NAME] 临时激活密钥回收失败:请手动吊销 owner=$seed_owner name=svc-plugin-activate"
fi
rm -f "$plugins_body" "$commands_body" "$registry_body"
if [ -n "$missing" ] || [ -n "$disabled" ]; then
c_err "[$ENV_NAME] 插件未激活(缺失:${missing:-无};未启用:${disabled:-无})"
return 1
fi
c_ok "[$ENV_NAME] 插件已激活:${COMPOSE_PLUGINS_KV}"
return 0
}
# ---- agent:部署与启动 ----
# source_kind_prefix LABEL:取 source= 的种类前缀(dev@sha → dev;空 → 空)。
@@ -2607,6 +3099,10 @@ deploy_rdm_companion_ui() {
assert_ui_ready() {
local src="${SRC_LABEL:-$(svc_get source)}"
local code url host port addr cname probe
# 组合部署里带 web 的插件走伴生工作台,source 本身仍是 dev@/local@。
if [ -n "${COMPOSE_WEB_ID:-}" ]; then
src="rdm@${COMPOSE_WEB_ID}"
fi
case "$src" in
rdm@*)
cname="$(rdm_companion_name "$ENV_NAME")"
@@ -2650,11 +3146,31 @@ assert_ui_ready() {
}
agent_do_deploy() {
case "${CLI_FROM:-}" in
rdm|rdm@*|release|release@*)
if [ "${CLI_WITH_SET:-0}" = 1 ] && [ "$CLI_WITH" != none ]; then
c_err "[$ENV_NAME] --from ${CLI_FROM%%@*} 不能与 --with 一起用(release 是成品;rdm 源用组合仓自己的 pins。要选插件请用 --from dev 或 --from local)"
hist_event deploy fail "$(read_pid)" "插件选择与来源互斥"
return 1
fi
;;
esac
resolve_source
if ! resolve_plugins; then
hist_event deploy fail "$(read_pid)" "插件选择失败"
return 1
fi
if [ -n "${COMPOSE_LABEL_SUFFIX:-}" ]; then
SRC_LABEL="${SRC_LABEL}${COMPOSE_LABEL_SUFFIX}"
fi
if ! require_allow_source_switch; then
hist_event deploy fail "$(read_pid)" "换源种类需 --allow-source-switch($SRC_LABEL)"
return 1
fi
if ! compose_require_switch; then
hist_event deploy fail "$(read_pid)" "插件集合变化需 --allow-source-switch"
return 1
fi
if [ "$CLI_DRYRUN" = 1 ]; then print_agent_plan; return 0; fi
# 停旧之前的静态配置验证:数据库配置可解析(绑定有效/无冲突)、conffile 存在、
# 现产物承接能力——配置性问题不制造停机窗口(登记保持可恢复,失败直接返回)。
@@ -2672,17 +3188,62 @@ agent_do_deploy() {
|| { 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" ;;
if [ -n "${COMPOSE_PLUGINS_KV:-}" ] && [ "${SVC_COMPOSE_STUB:-}" != 1 ]; then
c_info "[$ENV_NAME] 组合构建:先构建前端,再在隔离副本编入插件(不改主检出 go.mod)"
make -C "$ROOT" frontend || {
c_err "[$ENV_NAME] 前端构建失败($SRC_LABEL)——旧服务保持运行、制品与登记不变"
hist_event deploy fail "$(read_pid)" "前端构建失败($SRC_LABEL)"
return 1
}
COMPOSE_LOCAL_DIR="$RUNTIME/.worktrees/compose-local-$$"
rm -rf "$COMPOSE_LOCAL_DIR"
mkdir -p "$COMPOSE_LOCAL_DIR/backend" || return 1
if ! rsync -a --exclude .git "$ROOT/modules/backend/" "$COMPOSE_LOCAL_DIR/backend/"; then
c_err "[$ENV_NAME] 无法复制基座副本($SRC_LABEL)"
hist_event deploy fail "$(read_pid)" "基座副本失败($SRC_LABEL)"
svc_deploy_recycle_wt
return 1
fi
if ! compose_build_into "$COMPOSE_LOCAL_DIR/backend" "$COMPOSE_LOCAL_DIR/agent-system"; then
c_err "[$ENV_NAME] 组合构建失败($SRC_LABEL)——旧服务保持运行、制品与登记不变"
hist_event deploy fail "$(read_pid)" "组合构建失败($SRC_LABEL)"
svc_deploy_recycle_wt
return 1
fi
srcbin="$COMPOSE_LOCAL_DIR/agent-system"; srcplug="$ROOT/plugins"
[ -n "${COMPOSE_HTDOCS:-}" ] && srchtdocs="$COMPOSE_HTDOCS"
else
if [ -n "${COMPOSE_PLUGINS_KV:-}" ]; then
c_info "[$ENV_NAME] 组合构建桩(SVC_COMPOSE_STUB=1,不执行 go build)"
else
c_info "[$ENV_NAME] 构建当前工作区(make build)——唯一写主检出构建产物的路径"
fi
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"
fi
;;
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" ;;
if [ -n "${COMPOSE_PLUGINS_KV:-}" ] && [ "${SVC_COMPOSE_STUB:-}" != 1 ]; then
wt="$(materialize_dev_commit "$SRC_FULL" "$SRC_SHA" frontend)" \
|| { hist_event deploy fail "$(read_pid)" "物化/前端构建失败($SRC_LABEL)"; return 1; }
mkdir -p "$wt/bin"
if ! compose_build_into "$wt/modules/backend" "$wt/bin/agent-system"; then
hist_event deploy fail "$(read_pid)" "组合构建失败($SRC_LABEL)"
svc_deploy_recycle_wt
return 1
fi
srcbin="$wt/bin/agent-system"; srcplug="$wt/plugins"
[ -n "${COMPOSE_HTDOCS:-}" ] && srchtdocs="$COMPOSE_HTDOCS"
else
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"
fi
;;
release)
local relbin="$REL_ROOT/build/$REL_VERSION/agent-system"
if [ ! -f "$relbin" ]; then
@@ -2751,6 +3312,11 @@ agent_do_deploy() {
hist_event deploy fail "$(read_pid)" "启动失败已回退($SRC_LABEL)"
return 1
fi
if ! assert_plugins_active; then
svc_rollback_deploy "插件未激活($SRC_LABEL)" || true
hist_event deploy fail "$(read_pid)" "插件未激活(已回退)"
return 1
fi
case "$SRC_KINDSRC" in
rdm)
# 顶层 htdocs → current/htdocs 符号链接:伴生经它伺服,指针切换自动换内容
@@ -2773,6 +3339,23 @@ agent_do_deploy() {
fi
;;
esac
if [ -n "${COMPOSE_WEB_ID:-}" ] && [ "$SRC_KINDSRC" != rdm ]; then
if [ ! -f "${COMPOSE_HTDOCS:-}/index.html" ]; then
svc_rollback_deploy "插件 $COMPOSE_WEB_ID 无 web 工作台(ui-missing)" || true
hist_event deploy fail "$(read_pid)" "ui-missing(插件无 web/,已回退)"
return 1
fi
if [ ! -L "$ENV_DIR/htdocs" ]; then
rm -rf "$ENV_DIR/htdocs"
ln -s current/htdocs "$ENV_DIR/htdocs"
fi
c_ok "[$ENV_NAME] 插件 $COMPOSE_WEB_ID 工作台就位: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 "插件伴生工作台部署失败(ui-missing)" || true
hist_event deploy fail "$(read_pid)" "ui-missing(伴生部署失败,已回退)"
return 1
fi
fi
if ! assert_ui_ready; then
svc_rollback_deploy "UI 探活失败(ui-missing)" || true
hist_event deploy fail "$(read_pid)" "ui-missing(已回退)"
@@ -2786,6 +3369,14 @@ agent_do_deploy() {
hist_event deploy fail "$(read_pid)" "manifest 领养失败(已回退)"
return 1
fi
COMPOSE_PLUGINS_COMMIT=1
if ! record_state; then
COMPOSE_PLUGINS_COMMIT=0
SRC_LABEL=""
svc_rollback_deploy "插件登记写入失败($SRC_LABEL)" || true
hist_event deploy fail "$(read_pid)" "插件登记写入失败(已回退)"
return 1
fi
svc_release_commit_schema "$relid"
svc_journal_clear
svc_release_gc
@@ -2817,6 +3408,16 @@ print_agent_plan() {
printf ' 工作台:web/ → %s/htdocs;自动伴生:scripts/svc.sh %s deploy --kind static --dir 该目录\n' \
"$ENV_DIR" "$(rdm_companion_name "$ENV_NAME")" ;;
esac
if [ -n "${COMPOSE_PLUGINS_KV:-}" ]; then
printf ' 插件:%s\n' "$COMPOSE_PLUGINS_KV"
printf ' 构建:基座副本生成 zz_plugins_gen.go,go build -mod=mod 得到单一 agent-system\n'
if [ -n "${COMPOSE_WEB_ID:-}" ]; then
printf ' 工作台:插件 %s 的 %s → 伴生 %s\n' \
"$COMPOSE_WEB_ID" "$COMPOSE_WEB_REL" "$(rdm_companion_name "$ENV_NAME")"
fi
elif [ "${COMPOSE_PLUGINS_SET:-0}" = 1 ]; then
printf ' 插件:无\n'
fi
return 0
}
@@ -5190,7 +5791,9 @@ cmd_update() {
"")
state="来源未知(重新 deploy 后可参与更新)" ;;
local@*|dev@*)
cur="${src##*@}"
# dev@<基座>+<插件>@<提交> 的基座在第一个 + 之前,不能用最后一个 @。
cur="${src%%+*}"
cur="${cur#*@}"
if [ -z "$latest_sha" ]; then
state="远端 main 未知"; latest_disp="-"
elif ! git -C "$SRC_DIR" cat-file -e "${cur}^{commit}" 2>/dev/null; then
@@ -5282,7 +5885,15 @@ cmd_update() {
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
# 原样带上 plugins=,避免在终端上再次询问,也避免 ${src##*@} 把插件提交当成基座。
local plug plug_args=()
plug="$(sed -n 's/^plugins=//p' "$RUNTIME/$name/svc.env" | head -1)"
if [ -n "$plug" ]; then
plug_args=(--with "$plug")
else
plug_args=(--with none)
fi
if svc_lifecycle_child deploy "$name" --from "$deploy_from" ${plug_args[@]+"${plug_args[@]}"}; then
hist_event update ok "$(read_pid)" "from=$from to=$tov"
c_ok "[$name] 已更新:$from → $tov"
updated=$((updated + 1))
@@ -5499,6 +6110,7 @@ svc_lifecycle_main() {
while [ $# -gt 0 ]; do
case "$1" in
--from) CLI_FROM="$2"; shift 2 ;;
--with) CLI_WITH="$2"; CLI_WITH_SET=1; shift 2 ;;
--quiet) CLI_QUIET=1; shift ;;
*) c_err "__lifecycle 未知参数:$1"; exit 2 ;;
esac
@@ -5616,8 +6228,8 @@ main() {
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 动作"
if { [ -n "$CLI_FROM" ] || [ -n "$CLI_PICK" ] || [ "$CLI_DRYRUN" = 1 ] || [ -n "$CLI_DIR" ] || [ "$CLI_ALLOW_SOURCE_SWITCH" = 1 ] || [ "$CLI_WITH_SET" = 1 ]; } && [ "$action" != deploy ]; then
c_err "--from/--pick/--dry-run/--dir/--allow-source-switch/--with 仅用于 deploy 动作"
exit 2
fi
env_config "$ENV_NAME"
+297
View File
@@ -0,0 +1,297 @@
"""svc.sh --with 编译插件组合的桩回归。
用临时 git 仓和桩二进制覆盖:登记校验、dry-run、与 rdm/release 互斥、
首次部署、非交互复用、换集合门禁、交互勾选,以及激活校验里“命令被禁用则启用”。
SVC_COMPOSE_STUB=1 不执行 go build。真实组合构建与 make test-lifecycle 不在这里。
"""
import os
import shutil
import subprocess
import tempfile
import unittest
from pathlib import Path
SOURCE = Path(__file__).resolve().parents[1]
SVC_SCRIPT = SOURCE / "scripts" / "svc.sh"
STUB = r"""#!/usr/bin/env bash
set -u
trace_dir="${SVC_STUB_TRACE:-$PWD/.svc-stub-trace}"
mkdir -p "$trace_dir"
case "${1:-}" in
version)
if [ "${2:-}" = "--json" ]; then
printf '%s\n' '{"protocol":1,"product":"agent-system","version":"stub","identity":{"ops":["ensure","verify"],"open":"existing-no-migrate","dsn":"env"},"cli":{"unknownSubcommand":"reject"}}'
exit 0
fi
echo "stub-agent-system 1.0"
exit 0 ;;
serve)
shift
addr=""
while [ $# -gt 0 ]; do
case "$1" in --addr) addr="$2"; shift 2 ;; *) shift ;; esac
done
port="${addr##*:}"; host="${addr%:*}"
trap 'kill "$child" 2>/dev/null; exit 0' TERM INT
python3 - "$port" "$host" "$trace_dir" <<'PYSRV' >/dev/null 2>&1 &
import json, os, sys, threading, time
from http.server import BaseHTTPRequestHandler, HTTPServer
port, host, trace = int(sys.argv[1]), sys.argv[2], sys.argv[3]
class H(BaseHTTPRequestHandler):
def _send(self, body):
raw = body.encode() if isinstance(body, str) else body
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(raw)))
self.end_headers()
self.wfile.write(raw)
def do_GET(self):
path = self.path.split('?', 1)[0]
if path == '/api/plugins':
self._send(json.dumps([{"manifest": {"id": "demo"}, "backend": {"mode": "inproc"}}]))
return
if path == '/api/registry':
self._send(json.dumps({"compiled": ["demo"]}))
return
if path == '/api/commands':
enabled = os.path.exists(os.path.join(trace, 'plugin-enabled'))
self._send(json.dumps([{"id": "demo.ping", "enabled": enabled}]))
return
self._send('{"ok":true}')
def do_HEAD(self):
self._send(b'')
def do_POST(self):
path = self.path.split('?', 1)[0]
n = int(self.headers.get('Content-Length') or 0)
self.rfile.read(n)
if path.endswith('/enabled'):
open(os.path.join(trace, 'plugin-enabled'), 'w').close()
open(os.path.join(trace, 'enable-post'), 'w').write(path + '\n')
self._send('{"ok":true}')
def log_message(self, *args):
pass
ppid = os.getppid()
def watch():
while True:
try:
if os.getppid() != ppid:
os._exit(0)
except Exception:
os._exit(0)
time.sleep(0.3)
threading.Thread(target=watch, daemon=True).start()
try:
HTTPServer((host, port), H).serve_forever()
except Exception:
os._exit(1)
PYSRV
child=$!
wait "$child"
;;
*)
exit 0 ;;
esac
"""
class PluginComposeTests(unittest.TestCase):
def setUp(self):
tmp = tempfile.mkdtemp(prefix="svc-plugins-")
self.addCleanup(shutil.rmtree, tmp, True)
self.root = Path(tmp)
(self.root / "scripts").mkdir(parents=True)
shutil.copy2(SVC_SCRIPT, self.root / "scripts" / "svc.sh")
self.trace = self.root / "trace"
self.stub = self.root / "stub-agent-system"
self.stub.write_text(STUB)
self.stub.chmod(0o755)
(self.root / "plugins").mkdir()
(self.root / "Makefile").write_text(
"build:\n\tmkdir -p bin\n\tcp %s bin/agent-system\n" % self.stub)
self.plugin = Path(tempfile.mkdtemp(prefix="svc-plugin-src-"))
self.addCleanup(shutil.rmtree, self.plugin, True)
self.cache = self.root / "plugin-cache"
self.rdm_cache = self.root / "rdm-cache"
self.env = os.environ.copy()
self.env.update({
"GIT_CONFIG_NOSYSTEM": "1", "GIT_CONFIG_GLOBAL": "/dev/null",
"GIT_ALLOW_PROTOCOL": "file", "GIT_TERMINAL_PROMPT": "0",
"GIT_AUTHOR_NAME": "svc plugins", "GIT_COMMITTER_NAME": "svc plugins",
"GIT_AUTHOR_EMAIL": "svc-plugins@example.invalid",
"GIT_COMMITTER_EMAIL": "svc-plugins@example.invalid",
"SVC_STUB_TRACE": str(self.trace),
"SVC_SRC_DIR": str(self.root / "srccache"),
"SVC_COMPOSE_STUB": "1",
"SVC_PLUGIN_SRC_ROOT": str(self.cache),
"SVC_RDM_SRC_DIR": str(self.rdm_cache),
"SVC_DEMO_ORIGIN": self.plugin.resolve().as_uri(),
})
for key in list(self.env):
if key.startswith("SVC_") and key not in (
"SVC_STUB_TRACE", "SVC_SRC_DIR", "SVC_COMPOSE_STUB",
"SVC_PLUGIN_SRC_ROOT", "SVC_RDM_SRC_DIR", "SVC_DEMO_ORIGIN",
):
self.env.pop(key)
for key in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE"):
self.env.pop(key, None)
self.git(self.plugin, "init", "--initial-branch=main")
(self.plugin / "README").write_text("demo\n")
self.git(self.plugin, "add", "README")
self.git(self.plugin, "commit", "-m", "demo plugin")
self.plugin_sha = self.git(self.plugin, "rev-parse", "HEAD").stdout.strip()
self.write_registry([self.plugin_entry()])
self.git(self.root, "init", "--initial-branch=main", ".")
self.git(self.root, "add", ".")
self.git(self.root, "commit", "-m", "fixture")
def plugin_entry(self, plugin_id="demo", web=""):
entry = {
"id": plugin_id,
"name": plugin_id,
"repo": self.plugin.resolve().as_uri(),
"repoEnv": "SVC_DEMO_ORIGIN",
"defaultRef": "origin/main",
"goModule": "example.com/" + plugin_id,
"entryPackage": "example.com/%s/plugin" % plugin_id,
"commandPrefix": plugin_id + ".",
}
if web:
entry["web"] = web
return entry
def write_registry(self, plugins):
import json
(self.root / "plugins" / "registry.json").write_text(
json.dumps({"plugins": plugins}, ensure_ascii=False, indent=2) + "\n")
def git(self, cwd, *args):
proc = subprocess.run(
["git", *args], cwd=cwd, env=self.env, text=True,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=60)
self.assertEqual(proc.returncode, 0, proc.stdout)
return proc
def tearDown(self):
runtime = self.root / "runtime"
if not runtime.is_dir():
return
for d in sorted(runtime.iterdir()):
if (d / "svc.pid").exists():
subprocess.run(
["bash", str(self.root / "scripts" / "svc.sh"), d.name, "stop"],
cwd=self.root, env=self.env, timeout=60,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def svc(self, *args, check=True, env=None, input_text=None):
proc = subprocess.run(
["bash", str(self.root / "scripts" / "svc.sh"), *map(str, args)],
cwd=self.root, env=env if env is not None else self.env, text=True,
input=input_text, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
timeout=120)
if check:
self.assertEqual(proc.returncode, 0, proc.stdout)
return proc
def kv(self, name, key):
envfile = self.root / "runtime" / name / "svc.env"
if not envfile.exists():
return None
for line in envfile.read_text().splitlines():
if line.startswith(key + "="):
return line[len(key) + 1:]
return None
def pid_alive(self, name):
pidf = self.root / "runtime" / name / "svc.pid"
if not pidf.exists():
return False
try:
os.kill(int(pidf.read_text().strip()), 0)
return True
except (ValueError, ProcessLookupError, PermissionError):
return False
def test_help_plugins_and_rejects_with_off_deploy(self):
help_out = self.svc("help", "plugins")
self.assertIn("registry.json", help_out.stdout)
self.assertIn("--with", help_out.stdout)
self.assertIn("allow-source-switch", help_out.stdout)
rejected = self.svc("pdemo", "start", "--with", "demo", check=False)
self.assertEqual(rejected.returncode, 2, rejected.stdout)
self.assertIn("仅用于 deploy", rejected.stdout)
def test_dry_run_prints_plugin_sha_without_listening(self):
result = self.svc("pdemo", "deploy", "--from", "local", "--with", "demo",
"--port", "18981", "--dry-run")
self.assertIn(self.plugin_sha, result.stdout)
self.assertIn("插件:", result.stdout)
self.assertFalse((self.root / "runtime" / "pdemo" / "svc.pid").exists())
def test_unknown_plugin_and_two_web_and_rdm_are_rejected(self):
unknown = self.svc("pdemo", "deploy", "--from", "local", "--with", "nope",
"--dry-run", check=False)
self.assertNotEqual(unknown.returncode, 0)
self.assertIn("没有插件", unknown.stdout)
self.write_registry([
self.plugin_entry("a", web="web"),
self.plugin_entry("b", web="web"),
])
two = self.svc("pdemo", "deploy", "--from", "local", "--with", "a,b",
"--dry-run", check=False)
self.assertNotEqual(two.returncode, 0)
self.assertIn("web", two.stdout)
rdm = self.svc("pdemo", "deploy", "--from", "rdm", "--with", "demo",
"--dry-run", check=False)
self.assertNotEqual(rdm.returncode, 0)
self.assertIn("不能与 --with", rdm.stdout)
self.assertFalse(self.rdm_cache.exists())
def test_deploy_reuses_selection_and_gates_clearing(self):
first = self.svc("pdemo", "deploy", "--from", "local", "--with", "demo",
"--port", "18981")
self.assertIn("插件已激活", first.stdout)
self.assertTrue((self.trace / "enable-post").exists())
plugins = self.kv("pdemo", "plugins")
self.assertTrue(plugins and plugins.startswith("demo@" + self.plugin_sha), plugins)
source = self.kv("pdemo", "source")
self.assertIn("+demo@", source)
release = (self.root / "runtime" / "pdemo" / "current" / "release.json").read_text()
self.assertIn(self.plugin_sha, release)
self.assertTrue(self.pid_alive("pdemo"))
again = self.svc("pdemo", "deploy", "--from", "local", "--port", "18981")
self.assertEqual(self.kv("pdemo", "plugins"), plugins)
self.assertIn("+demo@", self.kv("pdemo", "source"))
self.assertIn("插件已激活", again.stdout)
pid = int((self.root / "runtime" / "pdemo" / "svc.pid").read_text())
blocked = self.svc("pdemo", "deploy", "--from", "local", "--with", "none",
"--port", "18981", check=False)
self.assertNotEqual(blocked.returncode, 0, blocked.stdout)
self.assertIn("allow-source-switch", blocked.stdout)
self.assertTrue(self.pid_alive("pdemo"))
self.assertEqual(int((self.root / "runtime" / "pdemo" / "svc.pid").read_text()), pid)
self.assertEqual(self.kv("pdemo", "plugins"), plugins)
cleared = self.svc("pdemo", "deploy", "--from", "local", "--with", "none",
"--allow-source-switch", "--port", "18981")
self.assertIsNone(self.kv("pdemo", "plugins"))
self.assertNotIn("+demo@", self.kv("pdemo", "source"))
self.assertIn("插件集合已确认", cleared.stdout)
self.svc("pdemo", "stop")
def test_prompt_selects_numbered_plugin_on_first_deploy(self):
env = dict(self.env, SVC_FORCE_PLUGIN_PROMPT="1")
result = self.svc("pprompt", "deploy", "--from", "local", "--port", "18982",
env=env, input_text="1\n")
self.assertIn("插件已激活", result.stdout)
plugins = self.kv("pprompt", "plugins")
self.assertTrue(plugins and plugins.startswith("demo@"), plugins)
self.svc("pprompt", "stop")
if __name__ == "__main__":
unittest.main()