From 1cf2f533f756464f8d4eb4a648f26069913c2a62 Mon Sep 17 00:00:00 2001 From: weichen Date: Wed, 14 Oct 2026 16:08:13 +0000 Subject: [PATCH] Compile selected plugins into the svc deploy binary Why: deploy needs to choose registered plugins and ship them inside one agent-system, then refuse the release when they are not active. Evidence: python3 -m unittest tests.test_svc_plugins tests.test_svc_user_help tests.test_svc tests.test_svc_u2 (SELF, 63 passed, 1 skipped). Real compose build and make test-lifecycle are NOT_RUN. Impact: --with is mutually exclusive with --from rdm and --from release. Existing non-interactive deploys stay without plugins unless plugins= is already recorded. Changing the plugin id set on a live service requires --allow-source-switch. Refs: .task/feature-svc-plugin-compose.md modules/documents/docs/proposals/2026-10-14-svc-plugin-compose-deploy.md Co-authored-by: Cursor --- .task/feature-svc-plugin-compose.md | 26 +- PROJECT.md | 2 + docs/svc.md | 21 + modules/backend | 2 +- modules/documents | 2 +- plugins/README.md | 2 + plugins/registry.json | 15 + scripts/svc.sh | 660 +++++++++++++++++++++++++++- tests/test_svc_plugins.py | 297 +++++++++++++ 9 files changed, 988 insertions(+), 39 deletions(-) create mode 100644 plugins/registry.json create mode 100644 tests/test_svc_plugins.py diff --git a/.task/feature-svc-plugin-compose.md b/.task/feature-svc-plugin-compose.md index 257c1b9b..e3ceebb6 100644 --- a/.task/feature-svc-plugin-compose.md +++ b/.task/feature-svc-plugin-compose.md @@ -7,16 +7,16 @@ - 权威仓:ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Dev.git;目标分支:main;R0(initial_base_sha)= a9fa63f657e0dad1033576fd6783f63ccfabcc4e - 工作区:/home/weichen/projects/Agent-System-Dev-wt-feature-svc-plugin-compose(worktree);分支:feature/svc-plugin-compose -- 授权与交付终点:<本次允许的动作与终点;未授权事项写明> -- 交付模式:manual(前序人工决策,智能体 Review/合入);其他阶段明确授权后可记为 authorized-continuous +- 授权与交付终点:用户要求按已附设计实现全部待办(登记、组合构建、激活校验、文档与桩测试)。未授权推送、合入、改写历史。方案文档状态保持待评审。 +- 交付模式:manual(阶段结论未逐条另签;实现范围来自用户对所附设计的实施指示) - 合入执行:agent(已授权交付范围内,Review 通过、冲突解决且当前 HEAD 的 CI 通过后合入) -- 自动推进授权:无;授权范围:无;授权来源:无;授权终点:无;停止条件:无(未授权) -- 当前阶段:需求拆解;当前增量:I1(待定义) +- 自动推进授权:无;授权范围:实现所附设计并提交到本任务分支;授权来源:用户实施指示;授权终点:本分支本地提交;停止条件:未授权的推送与合入 +- 当前阶段:实施与自检;当前增量:U1 Backend、U2 RDM、U3 Dev(本地提交,未推送) - 流程依据:仓库根目录 policy/delivery.md(归档后仍按仓库根目录定位) ## 目标与非目标 -<预期结果、受影响入口/模块/契约;明确不做什么。> +`svc.sh deploy` 可以用 `--with` 或交互勾选,把 `plugins/registry.json` 里的插件与本次 Dev 基座编成一个 `agent-system`,部署后校验已注册且命令面启用。不做 sidecar,不删除旧 `--from rdm`。真实 `go build` 组合与 `make test-lifecycle` 本轮 NOT_RUN。 ## 关联方向与规划 @@ -28,13 +28,13 @@ | ID | 描述 | 项目符合度及依据 | 优先级 | 验收条件 | 依赖 | 结论 | |---|---|---|---|---|---|---| -| R1 | 待填写 | 待评估 | 待定 | 待定义 | 待确认 | 待评审 | +| R1 | deploy 选择插件并与基座编成一个二进制,部署后确认激活 | 符合所附设计;基座以 `--from` 的 Dev 提交为准 | 本轮 | 桩测试覆盖选择、互斥、复用、换集合与激活启用;真实组合 NOT_RUN | Backend HostOptions、RDM 入口、Dev 登记表 | 按用户实施指示落地,方案文档仍待评审 | ## 方案与计划 -- 方案引用:待填写(含对象版本) -- 实施计划引用:待填写(含增量与需求 ID) -- 测试计划引用:待填写(含需求 ID 与验收条件) +- 方案引用:modules/documents/docs/proposals/2026-10-14-svc-plugin-compose-deploy.md(待评审,不是已批准结论) +- 实施计划引用:所附设计的 U1 Backend、U2 RDM、U3 Dev +- 测试计划引用:tests/test_svc_plugins.py 覆盖 R1 的选择与激活桩;真实组合与 make test-lifecycle 记 NOT_RUN ## 阶段评审 @@ -55,7 +55,7 @@ | 增量/试验 | 假设 | 范围 | 判断和停止条件 | 批准依据 | 结果 | 变更影响 | 需重审补测 | |---|---|---|---|---|---|---|---| -| 待定义 | — | — | — | 未批准 | NOT_RUN | 待评估 | 待评估 | +| 步骤 0 组合构建 | 注入空白导入并 go build -mod=mod 能编进 RDM | 临时目录,不留代码 | 构建失败则停止实现 | 所附设计步骤 0 | SELF:构建成功,golang.org/x 版本未抬高,二进制含 rdm.project.create 与 rdm.ping | 无仓库变更 | 真实部署仍 NOT_RUN | ## 同步记录 @@ -79,9 +79,9 @@ ## 结果与验证(完成后填写) -- 结果: -- 验证:<结论摘要 PASS/FAIL/SKIP/NOT_RUN 逐项;逐条证据由 make evidence 写入上方证据表> -- 影响与剩余项: +- 结果:Dev 侧 `--with` 已接到 deploy、update、登记与回退。Backend `4c9743a` 与 RDM `3b2ab11` 在各自任务分支本地提交,未推送。 +- 验证:桩测试 PASS(SELF,见证据表)。真实组合 `go build` 部署与 `make test-lifecycle` 为 NOT_RUN。CI 为 NOT_RUN。 +- 影响与剩余项:方案文档仍待评审。RDM 的基座 pin 指向尚未推送的 Backend 提交,发布顺序须先 Backend 再 RDM。旧 `--from rdm` 保留。 ## 规划进展与下一步 diff --git a/PROJECT.md b/PROJECT.md index 1098ebcc..5fbc3501 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -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) 维护实际结果与剩余限制;后续单元须另行授权。 diff --git a/docs/svc.md b/docs/svc.md index c6a3b6cb..8b33fdbd 100644 --- a/docs/svc.md +++ b/docs/svc.md @@ -381,6 +381,27 @@ scripts/svc.sh 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@ --with rdm --dry-run +scripts/svc.sh dev deploy --from local --with rdm@ +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` 的插件最多一个,复用伴生 `web`。 + +`SVC_COMPOSE_STUB=1` 只给测试跳过真实 `go build`。真实组合构建与 `make test-lifecycle` 不在桩测试里。 + ## 6. 命令参考:全局命令 ### 6.1 list / status(总览) diff --git a/modules/backend b/modules/backend index e6c3d0d8..4c9743ae 160000 --- a/modules/backend +++ b/modules/backend @@ -1 +1 @@ -Subproject commit e6c3d0d87608f3053c89151e8d0cf3fa3f8788f1 +Subproject commit 4c9743ae4257088a9c5b4cc2a5cbfde89b83503b diff --git a/modules/documents b/modules/documents index 46506aec..05328ab2 160000 --- a/modules/documents +++ b/modules/documents @@ -1 +1 @@ -Subproject commit 46506aec34a8803d1b7f4de1a048de9dba591da2 +Subproject commit 05328ab295b1e38d9eacbe49339b2b2fbf7b3583 diff --git a/plugins/README.md b/plugins/README.md index 957987a5..a787de9c 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -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`。 diff --git a/plugins/registry.json b/plugins/registry.json new file mode 100644 index 00000000..7c469a93 --- /dev/null +++ b/plugins/registry.json @@ -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" + } + ] +} diff --git a/scripts/svc.sh b/scripts/svc.sh index 33e9b39a..8d5636ec 100755 --- a/scripts/svc.sh +++ b/scripts/svc.sh @@ -41,6 +41,9 @@ # --from rdm[@] 下游组合仓 Agent-System-RDM(独立 RDM 源仓物化 pins/ 后 # make build 出 agent-system-rdm 组合二进制;web/ 工作台静态 # 资产随环境落 htdocs/,供伴生 static 服务 --dir 使用) +# --with id[,id…] 把登记表里的插件与基座编成一个二进制;id@ 钉提交 +# --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 deploy [--from dev@] 部署并启动(缺省=远端 main 最新) + scripts/svc.sh deploy [--from dev@] [--with id] 部署并启动(可把插件编进二进制) scripts/svc.sh start|stop|restart|logs [N] 生命周期与日志 scripts/svc.sh status 服务详情(版本/来源/DSN 等) scripts/svc.sh db [pg名|--clear] agent 数据库绑定/切换(缺省查看) @@ -184,13 +187,13 @@ svc.sh —— 本机服务管理:后端(agent) / 前端(static) / 数据库(po scripts/svc.sh rdm-admin [show] RDM 域管理员台账查看(含基座用户核验) scripts/svc.sh rdm-admin transfer --to 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@ | dev@ | release@<版本> | dir@<路径> | - postgres@local | clone@<名>) + postgres@local | clone@<名>)。编入插件时在基座标签后追加 + +@<短提交>(如 dev@abc+rdm@def) + plugins 本次编入的编译插件(id@完整提交,逗号分隔)。update 原样复用,不再询问 version 产物版本串(agent-system version 输出) started 最近启动时间 pidcheck 进程判定标记(agent=二进制路径、postgres=pgdata 路径、static=--directory) @@ -307,10 +313,15 @@ EOF htdocs/,并自动部署伴生 web(--kind static --dir)。 注意:rdm 源不支持 --mock 与 --config;模型设置播种 (--model-*/--tavily-keys)仅基座产物支持。 + --with id[,id…] 把 plugins/registry.json 里的插件与本次基座编成一个二进制。 + id@ 钉住该插件提交;--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@ 钉住该插件的提交 + --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 的插件复用伴生 web(最多一个)。 + +示例: + scripts/svc.sh dev deploy --from dev@73b2802 --with rdm --dry-run + scripts/svc.sh dev deploy --from local --with rdm@ + 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" diff --git a/tests/test_svc_plugins.py b/tests/test_svc_plugins.py new file mode 100644 index 00000000..6280a7fc --- /dev/null +++ b/tests/test_svc_plugins.py @@ -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()