diff --git a/.task/feature-svc-services-registry.md b/.task/feature-svc-services-registry.md index e1623ed1..eedb9244 100644 --- a/.task/feature-svc-services-registry.md +++ b/.task/feature-svc-services-registry.md @@ -6,11 +6,47 @@ - 权威仓:ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-System-Dev.git;目标分支:main;R0(initial_base_sha)= a2657bc25f6b43f9b3e1a23cea86e3ebcfd80ecc - 工作区:/home/weichen/projects/Agent-System-Dev-wt-feature-svc-services-registry(worktree);分支:feature/svc-services-registry -- 授权与交付终点:<本次允许的动作与终点;未授权事项写明> +- 授权与交付终点:用户授权实施 svc 服务化五项功能(见目标);终点 = 实现合入 main 并回收工作区。 + 仅改 Dev 仓 `scripts/svc.sh` 与 `Makefile`,不动模块 gitlink;不碰 Releases 仓与主检出运行现场。 ## 目标与非目标 -<预期结果、受影响入口/模块/契约;明确不做什么。> +目标(scripts/svc.sh v2 —— 服务模型泛化): + +1. **按版本/提交启动各类服务**:agent(后端单二进制,local/dev@sha/release@v 三源已有); + static(前端静态服务:`--from dev@sha|local` 构建 frontend dist → 环境内 htdocs,或 + `--dir PATH` 直接服务既有目录);postgres(见 2)。 +2. **数据库管理**:`svc.sh db create [--port --host --user --password --db --auth-mode --tag]` + ——initdb(默认 scram-sha-256)到环境目录,`pg_ctl` 生命周期、`pg_isready` 探活、 + `svc.sh db dsn [--reveal]` 打印连接串(默认掩码密码);鉴权/端口/用户/密码/库名 + 可配并持久化,供 agent 服务 --dsn 引用。 +3. **配置分层 + 标签**:`--config FILE`(--env 别名,dotenv)+ CLI 覆盖(优先级: + CLI > 配置文件 > 上次启动持久化配置(svc.env exec=) > 默认);`--tag` 可重复, + 持久化登记,`list --tag` 过滤。 +4. **服务总览**:`svc.sh list|status`(等价)——全部服务表格:名称/类型/状态/pid/地址/ + 来源(提交或版本)/标签;`svc.sh status` 单服务详情(版本、数据目录、DSN 等)。 +5. **生命周期**:stop/restart 按类型分派——agent/static 走 TERM→KILL;postgres 走 + `pg_ctl stop -m fast`(绝不裸杀 postmaster);新增 `svc.sh rm [--purge]` + (注销登记,--purge 连数据目录一起删,仅限 runtime// 内)。 + +非目标:不做守护进程/自动拉起/健康巡检(未来可演进二进制);不做 postgres 版本管理 +(用系统二进制,版本语义由数据目录+标签承载);static 不做 SPA 路由重写;不管理 +容器(全局非容器约束);不动 Releases 仓产物结构。 + +## 关键设计决策 + +- **脚本而非二进制**:Dev 仓是集成父仓(非分发单元),Go 代码都在 modules/*;本功能是 + 进程编排 + 状态文件,bash + /proc + dotenv 足够、零构建依赖;registry 用 dotenv 而非 + JSON 保持无 jq 可解析,将来迁二进制可直接沿用文件格式。 +- **服务 = runtime// 目录**(沿用上一版布局):登记文件 svc.env(dotenv,chmod 600) + 增加 kind(agent|postgres|static,缺省 agent 兼容旧登记)、tags、pidcheck、exec(上次 + 完整启动参数,裸 start/restart 复用)、kind 特有键;事件流 history.jsonl 不变。 +- **安全边界**:只写 runtime// 与 runtime/.worktrees/;只停"自己 pidfile + + cmdline 标记双校验"的进程;端口冲突预检保留;svc.env 含密码 → 600;DSN 默认不回显 + 密码。`--from local` 是唯一写主检出构建产物(bin/、node_modules/、dist/,均 gitignore) + 的路径,显式文档化;dev@sha/release/static 构建完全在隔离 worktree。 +- postgres 事实:宿主机有 linuxbrew postgresql@18 全套工具链(initdb/pg_ctl/pg_isready/ + psql),`pg_config --bindir` 可定位;SVC_PG_BIN 可覆盖。 ## 同步记录 @@ -20,6 +56,26 @@ ## 结果与验证(完成后填写) -- 结果: -- 验证: -- 影响与剩余项: +- 结果:svc.sh 升级为服务模型 v2(~1000 行):kind 三类(agent/static/postgres)、标签、 + --config 配置文件(--env 别名)、持久化配置复用(svc.env exec=/addr/...)、list 总览、 + db create/dsn、rm [--purge];Makefile 增 svc-list 并更新帮助。历史与登记文件格式不变, + 旧登记(无 kind=)自动视为 agent。 +- 验证(全部在任务工作区 runtime 内真实执行;进程均已停止、目录已清理): + - bash -n 通过;错误路径(保留名/非法名/db 缺参/rm 未登记/list --tag 缺值)均明确报错。 + - postgres 真实全链路(linuxbrew postgresql@18):db create(initdb scram-sha-256 + 随机/ + 指定密码,svc.env 600)→ start(pg_ctl + pg_isready 就绪 + 自动建库)→ psql 写入数据 → + restart 后数据仍在 → 错误密码被拒 → db dsn 掩码/--reveal → stop(pg_ctl fast)→ + rm --purge。测试中修复:psql 参数错位(-c -c)、record_state/write_static_reg 在重定向 + 块内读同文件导致 tags 丢失、postgres --version 字段解析。 + - static:--dir 直接服务(登记→HTTP 200→restart→stop);dev@sha dry-run 计划正确; + release 源明确报"不支持";--dir 不存在报错。 + - agent 回归:release@0.1.0 部署(sha256 校验、--tag stable、自定义端口 8791)→ HTTP 200 + → 裸 restart 复用 svc.env exec=(同端口同参数)→ list 三类服务混排正确 → status 详情 + (版本/标签/来源)→ commits release 标注 [已部署:prod]。 + - DSN 组合:`--dsn "$(svc.sh db dsn pg1 --reveal)"` 传入 agent 部署(0.1.0 旧二进制自动 + 省略 --dsn/--auth 并警告);db dsn stdout 纯净可捕获。 + - 旧格式 svc.env(仅 addr/data)→ list 显示 kind=agent;--config 别名生效; + history 支持纯数字首参。 +- 影响与剩余项:static dev@sha 真实构建未在本轮重跑(worktree 物化+make 构建路径与上一任务 + 合入的 agent 构建同机制,已验证过);static 深链路由不重写(http.server 行为); + svc 提示信息改走 stderr(stdout 只留数据),脚本管道组合更干净。 diff --git a/Makefile b/Makefile index 4ae4cf9d..254d6b0d 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ PLUGINS := plugins COMBO := core=$(shell git -C $(CORE) rev-parse --short HEAD 2>/dev/null)+backend=$(shell git -C $(BACKEND) rev-parse --short HEAD 2>/dev/null)+frontend=$(shell git -C $(FRONTEND) rev-parse --short HEAD 2>/dev/null)+ui=$(shell git -C $(UI) rev-parse --short HEAD 2>/dev/null)+agentcmd=$(shell git -C deps/agent-command rev-parse --short HEAD 2>/dev/null)+documents=$(shell git -C modules/documents rev-parse --short HEAD 2>/dev/null) VERSION := $(shell git describe --tags --always --dirty 2>/dev/null)+$(COMBO) -.PHONY: help sync status build frontend svc svc-commits svc-history test-backend test-frontend test-ui test-core test-agentcmd dev pin clean setup precheck task-start task-sync task-close task-finish version +.PHONY: help sync status build frontend svc svc-list svc-commits svc-history test-backend test-frontend test-ui test-core test-agentcmd dev pin clean setup precheck task-start task-sync task-close task-finish version version: @echo "agent-system $(VERSION)" @@ -45,9 +45,10 @@ help: @echo "make sync 物化全部 submodule(递归)" @echo "make build 构建单二进制 bin/agent-system(契约同步→前端构建→embed→静态链接)" @echo "make test 全量测试(core + backend + frontend)" - @echo "make svc 环境服务管理:make svc ENV=dev|prod [ACTION=deploy](停旧→构建→启动,环境隔离)" + @echo "make svc 服务级操作:make svc ENV= [ACTION=deploy|start|stop|restart|status|logs] [ARGS='--from dev@ --tag ...']" + @echo "make svc-list 服务总览:make svc-list [ARGS='--tag core'];数据库:scripts/svc.sh db create|dsn " @echo "make svc-commits 列出可部署提交:make svc-commits SRC=dev|release [N=20]" - @echo "make svc-history 启动/部署历史:make svc-history [ENV] [N=20]" + @echo "make svc-history 事件历史:make svc-history [ENV] [N=20]" @echo "make dev 开发模式说明(前后端分离热更新)" @echo "make pin 推进 submodule 指针到各仓远端 main(提交前须 make test 通过)" @echo "make status submodule 状态" @@ -96,9 +97,12 @@ test: test-core test-backend test-frontend test-ui test-agentcmd # 运行演示(mock 模式):单二进制 + 示例插件 + 嵌入式存储,浏览器 http://127.0.0.1:8787 # 环境服务管理(scripts/svc.sh):deploy = 停旧 → 构建 → 启动;dev/prod 相互隔离。 svc: - @test -n "$(ENV)" || (echo "用法:make svc ENV=dev|prod [ACTION=deploy|stop|start|restart|status|logs] [ARGS='--port 9000 --mock ...']"; exit 2) + @test -n "$(ENV)" || (echo "用法:make svc ENV= [ACTION=deploy|stop|start|restart|status|logs] [ARGS='--port 9000 --tag ...']"; exit 2) scripts/svc.sh $(ENV) $(ACTION) $(ARGS) +svc-list: + scripts/svc.sh list $(ARGS) + svc-commits: @test -n "$(SRC)" || (echo "用法:make svc-commits SRC=dev|release [N=20]"; exit 2) scripts/svc.sh commits $(SRC) $(N) diff --git a/scripts/svc.sh b/scripts/svc.sh index e2bed67e..a656a893 100755 --- a/scripts/svc.sh +++ b/scripts/svc.sh @@ -1,92 +1,77 @@ #!/usr/bin/env bash -# svc.sh —— 多环境服务管理:按环境隔离地 停止旧服务 → 取源(本地 / Dev 提交 / Release -# 版本)→ 构建 / 校验 → 启动,并登记启动记录。 +# svc.sh —— 本机服务管理(服务模型 v2):按提交/版本部署并启动 后端(agent) / 前端(static) +# / 数据库(postgres) 三类服务,统一登记(svc.env + history.jsonl)、标签、配置分层、 +# 生命周期(start/stop/restart/rm),全程不触碰主检出与其他已启动服务。 # # 用法: -# scripts/svc.sh [action] [参数...] -# env :任意环境名(^[a-z][a-z0-9-]{0,15}$;dev/prod 有默认配置,其余自动派生) -# action:deploy(默认)| stop | start | restart | status | logs [行数] -# scripts/svc.sh status # 全环境总览(扫描 runtime/* 下已登记环境) -# scripts/svc.sh commits [dev|release] [N] # 列出可部署提交(默认 dev,N 默认 20) -# scripts/svc.sh history [env] [N] # 启动/部署历史(默认全部环境,最近 20 条) +# scripts/svc.sh [action] [参数...] # 服务级操作(name 见命名规则) +# action:deploy(默认,agent/static)| start | stop | restart | status | logs [行数] +# scripts/svc.sh list [--tag T] # 全服务总览(status 等价) +# scripts/svc.sh db create [参数] # 创建 PostgreSQL 服务(initdb) +# scripts/svc.sh db dsn [--reveal] [--db X] # 打印数据库连接串(默认掩码密码) +# scripts/svc.sh rm [--purge] # 注销服务(--purge 连数据一起删) +# scripts/svc.sh commits [dev|release] [N] # 列出可部署提交(默认 dev,N=20) +# scripts/svc.sh history [name] [N] # 事件历史(默认全部,最近 20 条) # -# 部署来源(仅 deploy;默认 local): -# --from local 构建当前工作区(make build),复制产物到环境目录 -# --from dev 构建 Dev 仓(本仓)当前 HEAD 的 gitlink 组合 -# --from dev@ 在隔离 worktree(runtime/.worktrees/)物化该提交 -# 的子模块组合并构建——不触碰当前工作区;子模块对象缺失时 -# 需网络拉取 -# --from release Releases 仓最新版本制品(定位:SVC_RELEASES_DIR > -# ../Agent-System-Releases) -# --from release@<版本|sha> 指定 Releases 版本;制品取 build//,sha256 对 -# versions//manifest.json 校验,不符即中止 -# --pick [dev|release] 交互式编号选择提交后部署(仅 TTY;非交互请用 --from) -# --dry-run 只解析并打印部署计划,不执行 +# 服务模型: +# - name:^[a-z][a-z0-9-]{0,15}$(保留字不可用);dev/prod 有默认端口,其余按名字 +# 确定性派生(8800-9099,避让已登记/监听端口),SVC__ADDR 可覆盖 +# - 每个服务自包含于 runtime//:产物(bin/htdocs)/数据(data/pgdata)/svc.pid/ +# svc.log|pg.log/svc.env(登记+持久化配置,600)/history.jsonl(追加事件流) +# - kind(svc.env kind=,缺省 agent,兼容旧登记): +# agent 后端单二进制(内嵌前端);deploy 三源:local | dev[@] | release[@] +# static 前端静态服务;deploy --from dev[@sha]|local 构建 frontend dist 复制到 +# htdocs/,或 deploy --dir PATH 直接服务既有目录 +# postgres 数据库;db create 后 start/stop/restart(pg_ctl 生命周期,绝不裸杀) # -# 其余参数(优先级:命令行 > --env 配置文件 > 环境变量/.svc.env > 默认值): -# --env FILE 环境配置文件(dotenv:KEY=VALUE,支持 # 注释、成对引号、 -# export 前缀;只识别合法变量名,不执行文件内容)。 -# 任意环境 X 的变量名为 SVC__ADDR/DATA/DSN/AUTH/MOCK -# (如环境 staging → SVC_STAGING_ADDR) -# --addr host:port 监听地址(整体覆盖) -# --host host 只改主机部分(如 0.0.0.0 对外开放) -# --port port 只改端口部分 -# --data dir 数据目录(SQLite 与工件) -# --dsn DSN PostgreSQL 档 DSN(dev 外环境;含密码时建议用环境变量传递) -# --auth on|off|auto API key 认证(默认 auto:PG 档开、嵌入式关) -# --mock 演示模式(dev 常用) -# --plugins dir 插件目录(默认 /plugins,随 deploy 复制) -# --model-base URL 启动后自动播种主模型配置(settings.models.chat.baseUrl) -# --model-key KEY 主模型 API Key(播种用;勿写入脚本/仓库) -# --model-name NAME 主模型名称 -# --light-base/--light-key/--light-name 轻量模型(标题生成等;可选) -# --tavily-keys K1,K2 网络搜索密钥池(播种 tools.tavily.apiKeys) -# -- <剩余参数> 原样透传给 agent-system serve(escape hatch) +# 配置优先级(三类服务一致):命令行 > --config/--env 文件 > svc.env 持久化的上次配置 +# > SVC__* 系统环境变量(.svc.env 同)> 默认值。裸 start/restart 复用上次持久化 +# 配置(svc.env exec= 等),显式参数出现即覆盖。 # -# 环境与登记: -# - dev 默认 127.0.0.1:8788、prod 默认 127.0.0.1:8787;自定义环境默认端口按环境名 -# 确定性派生(8800 起,与已登记端口/监听端口冲突时自动递增),可 SVC__ADDR 覆盖 -# - 每个环境自包含:runtime//{bin/agent-system, plugins/, data/, svc.pid, -# svc.log, svc.env, history.jsonl}——跨环境互不串版本,回退 = 重新 deploy 旧来源 -# - 启动登记:svc.env(当前态:pid/addr/data/bin/source/version/started)+ -# history.jsonl(追加式事件流:deploy/start/stop);status 与 history 从这些文件 -# 汇总,不依赖脚本内存状态 -# - start/restart 使用环境目录内产物;环境无产物时先 deploy(旧版脚本部署过的环境 -# 无环境产物,同样需要重新 deploy 一次) -# -# 一次性配置:.svc.env(自动加载)或 --env 指定文件(如 .env.dev/.env.prod, -# 均建议 gitignore)。文件示例(.env.dev): -# SVC_DEV_ADDR=0.0.0.0:8790 -# SVC_MODEL_BASE=https://api.xxx.com/v1 -# SVC_MODEL_KEY=sk-xx -# SVC_MODEL_NAME=glm-4.7 +# 常用参数(服务级): +# --config FILE / --env FILE dotenv 配置文件(KEY=VALUE;SVC__ADDR/DSN/...) +# --addr host:port / --host H / --port P 监听地址(pg=TCP,static=HTTP) +# --tag T 标签(可重复;登记于 svc.env,list --tag 过滤) +# --kind agent|static deploy 时指定类型(postgres 用 db create 建) +# --from SOURCE 部署来源(见上);--dir PATH(static)直接服务目录 +# --dry-run 只打印部署计划;--pick [dev|release] 交互式选提交 +# --plugins/--dsn/--auth/--mock/--data 与 --model-* / --light-* / --tavily-keys +# (agent 播种/运行参数)同上一版 +# -- <剩余参数> 原样透传给 agent-system serve(escape hatch) +# db create 专属:--user U(默认 agent)--password P(缺省自动生成,db dsn --reveal 查看) +# --db NAME(默认 agent)--auth-mode scram-sha-256|md5(默认 scram) +# rm 专属:--purge 删除 runtime// 整个目录(含数据与历史) # # 示例: -# scripts/svc.sh commits dev # 看 Dev 仓可部署提交 -# scripts/svc.sh commits release # 看 Releases 仓版本 -# scripts/svc.sh staging deploy --pick # 交互选提交部署到新环境 staging -# scripts/svc.sh prod deploy --from dev@73b2802 # 部署 Dev 仓指定提交 -# scripts/svc.sh prod deploy --from release@0.1.0 # 部署(回退到)发布版本 -# scripts/svc.sh dev --env ./.env.dev # 用配置文件启动 -# scripts/svc.sh dev --env ./.env.dev --port 9000 -# scripts/svc.sh status && scripts/svc.sh history -# make svc ENV=dev ARGS="--env .env.dev --port 9000" +# 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@ --dsn "$(scripts/svc.sh db dsn pg1 --reveal)" +# scripts/svc.sh ui-r4 deploy --kind static --from dev@ --tag ui +# scripts/svc.sh ui-qa deploy --kind static --dir ../some/dist +# scripts/svc.sh list --tag core && scripts/svc.sh history +# scripts/svc.sh pg1 restart && scripts/svc.sh rm tmp1 --purge # # 安全约束: -# - 只停自己启动过的进程(pidfile + /proc//cmdline 双重校验),绝不动他人现场; -# - 端口被非本脚本进程占用时明确报错并给出占用者信息,不抢不杀; -# - dev@ 构建在隔离 worktree 进行,不改写当前工作区与子模块检出。 +# - 只写 runtime// 与 runtime/.worktrees/;不改主检出 git 状态、不动他人现场; +# --from local 是唯一写主检出构建产物(bin/、node_modules/、dist/,均 gitignore) +# 的路径,dev@sha/release/static 构建全部在隔离 worktree; +# - 只停自己启动的进程:pidfile + /proc//cmdline 标记双校验;postgres 走 +# pg_ctl -m fast 优雅停机,SIGKILL 不用于 postmaster; +# - 端口被非本脚本进程占用时报错并给出占用者,不抢不杀; +# - svc.env 含密码时权限 600;DSN 默认掩码密码(--reveal 才显示)。 # <> set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" RUNTIME="$ROOT/runtime" BIN="$ROOT/bin/agent-system" +SVC_ENVFILE="" - -c_info() { printf '\033[1;34m[svc]\033[0m %s\n' "$*"; } -c_ok() { printf '\033[1;32m[svc]\033[0m %s\n' "$*"; } -c_warn() { printf '\033[1;33m[svc]\033[0m %s\n' "$*"; } +# 提示信息走 stderr:stdout 只留给数据(提交表/服务表/DSN),便于 $( ) 捕获组合。 +c_info() { printf '\033[1;34m[svc]\033[0m %s\n' "$*" >&2; } +c_ok() { printf '\033[1;32m[svc]\033[0m %s\n' "$*" >&2; } +c_warn() { printf '\033[1;33m[svc]\033[0m %s\n' "$*" >&2; } c_err() { printf '\033[1;31m[svc]\033[0m %s\n' "$*" >&2; } usage() { @@ -97,7 +82,7 @@ usage() { # 引号,忽略注释/空行/非法行),不 source 执行文件内容。 load_env_file() { local f="$1" line k v - [ -f "$f" ] || { c_err "环境配置文件不存在:$f"; exit 2; } + [ -f "$f" ] || { c_err "配置文件不存在:$f"; exit 2; } while IFS= read -r line || [ -n "$line" ]; do line="${line%$'\r'}" case "$line" in ''|'#'*) continue ;; esac @@ -119,6 +104,9 @@ CLI_AUTH=""; CLI_MOCK=0; CLI_PLUGINS=""; CLI_EXTRA=""; CLI_LOGS_N="" CLI_MBASE=""; CLI_MKEY=""; CLI_MNAME="" CLI_LBASE=""; CLI_LKEY=""; CLI_LNAME=""; CLI_TAVILY="" CLI_FROM=""; CLI_PICK=""; CLI_DRYRUN=0 +CLI_KIND=""; CLI_DIR=""; CLI_PURGE=0; CLI_REVEAL=0 +CLI_DB_USER=""; CLI_DB_PASSWORD=""; CLI_DB_NAME=""; CLI_DB_AUTH="" +CLI_TAGS=() need_value() { [ $# -ge 2 ] || { c_err "参数 $1 需要值"; exit 2; }; } @@ -144,6 +132,15 @@ parse_args() { --pick) CLI_PICK="dev" case "${2:-}" in dev|release) CLI_PICK="$2"; shift 2 ;; *) shift ;; esac ;; --dry-run) CLI_DRYRUN=1; shift ;; + --kind) need_value "$@"; CLI_KIND="$2"; shift 2 ;; + --dir) need_value "$@"; CLI_DIR="$2"; shift 2 ;; + --tag) need_value "$@"; CLI_TAGS+=("$2"); shift 2 ;; + --purge) CLI_PURGE=1; shift ;; + --reveal) CLI_REVEAL=1; shift ;; + --user) need_value "$@"; CLI_DB_USER="$2"; shift 2 ;; + --password) need_value "$@"; CLI_DB_PASSWORD="$2"; shift 2 ;; + --db) need_value "$@"; CLI_DB_NAME="$2"; shift 2 ;; + --auth-mode) need_value "$@"; CLI_DB_AUTH="$2"; shift 2 ;; --) shift; CLI_EXTRA="$*"; break ;; -h|--help) usage; exit 0 ;; *) c_err "未知参数:$1"; usage; exit 2 ;; @@ -161,22 +158,21 @@ addr_compose() { echo "$host:$port" } -# ---- 环境泛化 ---- -RESERVED_ENVS=" status commits history deploy stop start restart logs help version " +# ---- 名称与端口 ---- +RESERVED_ENVS=" status list add db rm create dsn commits history deploy stop start restart logs help version " -# valid_env NAME:环境名合法性(小写字母开头、a-z0-9-、≤16 位;保留字不可用作环境名)。 +# valid_env NAME:服务名合法性(小写字母开头、a-z0-9-、≤16 位;保留字不可用)。 valid_env() { case "$RESERVED_ENVS" in *" $1 "*) return 1 ;; esac [[ "$1" =~ ^[a-z][a-z0-9-]{0,15}$ ]] } -# env_var ENV SUFFIX:取 SVC__ 变量值(无则空)。 +# env_var NAME SUFFIX:取 SVC__ 变量值(无则空)。 env_var() { local v="SVC_$(printf '%s' "$1" | tr 'a-z-' 'A-Z_')_$2" printf '%s' "${!v:-}" } -# ports_recorded PORT SKIP_ENV:除 SKIP_ENV 外,是否有环境登记了该端口(svc.env addr)。 ports_recorded() { local p="$1" skip="$2" d e a for d in "$RUNTIME"/*/; do @@ -189,15 +185,13 @@ ports_recorded() { return 1 } -# port_listening PORT:端口是否处于监听(ss 可用时)。 port_listening() { local p="$1" command -v ss >/dev/null 2>&1 || return 1 ss -ltn 2>/dev/null | awk -v p=":$p" '$4 ~ p"$" {f=1} END {exit !f}' } -# derive_port NAME:由环境名确定性派生端口(8800+hash%300),与已登记/监听端口冲突 -# 时在 8800..9099 内递增扫描;找不到空闲端口则失败。 +# derive_port NAME:由服务名确定性派生端口(8800+hash%300),冲突时在 8800..9099 递增。 derive_port() { local name="$1" p i=0 p=$(( 8800 + ($(printf '%s' "$name" | cksum | cut -d' ' -f1) % 300) )) @@ -211,120 +205,104 @@ derive_port() { return 1 } -env_config() { - local name="$1" - valid_env "$name" || { - c_err "非法环境名:$name(^[a-z][a-z0-9-]{0,15}$;保留字 status/commits/history 等不可用)" - exit 2 - } - ENV_NAME="$name" - ENV_DIR="$RUNTIME/$name" - ENV_PID="$ENV_DIR/svc.pid" - ENV_LOG="$ENV_DIR/svc.log" - ENV_BIN_DIR="$ENV_DIR/bin" - ENV_BIN="$ENV_BIN_DIR/agent-system" - ENV_PLUGINS="${CLI_PLUGINS:-$ENV_DIR/plugins}" - - local base_addr base_data - base_addr="$(env_var "$name" ADDR)" - base_data="$(env_var "$name" DATA)" - case "$name" in - dev) [ -z "$base_addr" ] && base_addr="127.0.0.1:8788" - [ -z "$base_data" ] && base_data="$RUNTIME/dev/data" ;; - prod) [ -z "$base_addr" ] && base_addr="127.0.0.1:8787" - [ -z "$base_data" ] && base_data="$RUNTIME/prod/data" ;; - *) - if [ -z "$base_addr" ]; then - local p - p="$(derive_port "$name")" || { - c_err "[$name] 无空闲派生端口(8800-9099),请用 --port/--addr 或 SVC_$(printf '%s' "$name" | tr 'a-z-' 'A-Z_')_ADDR 显式指定" - exit 1 - } - base_addr="127.0.0.1:$p" - fi - [ -z "$base_data" ] && base_data="$RUNTIME/$name/data" - ;; - esac - ENV_ADDR="$(addr_compose "$base_addr")" - ENV_DATA="${CLI_DATA:-$base_data}" - - # 运行参数:mock/DSN/auth(CLI 与环境变量取并集,CLI 优先;dev 保持只认 mock)。 - ENV_ARGS="" - if [ "$CLI_MOCK" = 1 ] || [ "$(env_var "$name" MOCK)" = "1" ]; then - ENV_ARGS="--mock" - elif [ "$name" != dev ]; then - local dsn="${CLI_DSN:-$(env_var "$name" DSN)}" - if [ -n "$dsn" ]; then ENV_ARGS="$ENV_ARGS --dsn $dsn"; fi - local auth="${CLI_AUTH:-$(env_var "$name" AUTH)}" - [ -z "$auth" ] && auth=auto - ENV_ARGS="$ENV_ARGS --auth $auth" - fi +# 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 } -# pid_alive :进程存在且 cmdline 是我们的 serve(防 PID 复用误杀)。 -pid_alive() { - local pid="$1" - [ -n "$pid" ] && [ -r "/proc/$pid/cmdline" ] || return 1 - local cmd - cmd="$( { tr '\0' ' ' < "/proc/$pid/cmdline"; } 2>/dev/null )" || return 1 - case "$cmd" in - *agent-system*serve*) return 0 ;; - *) return 1 ;; - esac +# 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 } -read_pid() { [ -f "$ENV_PID" ] && cat "$ENV_PID" 2>/dev/null || true; } +# ---- 登记与事件 ---- -# ---- 启动登记(svc.env 当前态 + history.jsonl 事件流)---- - -# jclean S:剥 JSON 字符串里不能出现的字符(本脚本只写简单 ASCII 值)。 jclean() { printf '%s' "$1" | tr -d '"\001-\037'; } -# hist_event EVENT RESULT PID DETAIL:向 runtime//history.jsonl 追加一条事件。 +# hist_event EVENT RESULT PID DETAIL:向 runtime//history.jsonl 追加事件。 hist_event() { mkdir -p "$ENV_DIR" 2>/dev/null || return 0 - printf '{"ts":"%s","event":"%s","env":"%s","source":"%s","result":"%s","pid":"%s","addr":"%s","detail":"%s"}\n' \ + 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 "${SRC_LABEL:-}")" \ + "$(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 } -# record_state:成功启动后把当前态写入 svc.env(status/commits 标注从此读取)。 -record_state() { - local pid prev_src - pid="$(read_pid)" - prev_src="" - [ -f "$ENV_DIR/svc.env" ] && prev_src="$(sed -n 's/^source=//p' "$ENV_DIR/svc.env")" - SVC_SOURCE="${SRC_LABEL:-${prev_src:-}}" - mkdir -p "$ENV_DIR" - printf 'env=%s\npid=%s\naddr=%s\ndata=%s\nbin=%s\nsource=%s\nversion=%s\nstarted=%s\n' \ - "$ENV_NAME" "${pid:-}" "$ENV_ADDR" "$ENV_DATA" "$ENV_BIN" \ - "${SVC_SOURCE:-}" "${SVC_VERSION:-}" "$(date -Is)" > "$ENV_DIR/svc.env" +# 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" } -do_stop() { - local pid; pid="$(read_pid)" - if [ -z "$pid" ]; then - c_info "[$ENV_NAME] 无 pidfile——没有由本脚本启动的实例" - return 0 - fi - if ! pid_alive "$pid"; then - c_warn "[$ENV_NAME] pidfile 指向的进程已不存在(pid=$pid),清理 pidfile" - rm -f "$ENV_PID" - return 0 - fi - c_info "[$ENV_NAME] 停止旧服务 pid=$pid(SIGTERM,等待优雅停机)" +# record_state EXTRA_KEY=VALUE...:把当前态+持久化配置写入 svc.env(600)。 +# 基本键:kind/env/pid/addr/tags/source/version/started/pidcheck/exec; +# kind 特有键(user/password/dbname/authmode/pgdata/htdocs...)经参数追加。 +record_state() { + # 先读完旧文件再开重定向(块内 {} > svc.env 会先截断文件,读旧值必须在其之前)。 + local pid prev_src merged_tags + pid="$(read_pid)" + prev_src="" + [ -f "$ENV_DIR/svc.env" ] && prev_src="$(svc_get source)" + SVC_SOURCE="${SRC_LABEL:-${prev_src:-}}" + merged_tags="$(svc_merge_tags)" + mkdir -p "$ENV_DIR" + { + printf 'kind=%s\n' "${SVC_KIND:-agent}" + printf 'env=%s\n' "$ENV_NAME" + printf 'pid=%s\n' "${pid:-}" + printf 'addr=%s\n' "$ENV_ADDR" + printf 'tags=%s\n' "$merged_tags" + printf 'source=%s\n' "${SVC_SOURCE:-}" + printf 'version=%s\n' "${SVC_VERSION:-}" + printf 'started=%s\n' "$(date -Is)" + printf 'pidcheck=%s\n' "${SVC_PIDCHECK:-}" + printf 'exec=%s\n' "${SVC_EXEC:-}" + [ $# -gt 0 ] && printf '%s\n' "$@" + } > "$ENV_DIR/svc.env" + chmod 600 "$ENV_DIR/svc.env" +} + +# ---- pid 管理(泛化:pidfile + cmdline 标记双校验)---- + +pid_alive_mark() { # PID MARKER:cmdline 含 MARKER 视为我们的进程 + local pid="$1" mark="$2" cmd + [ -n "$pid" ] && [ -r "/proc/$pid/cmdline" ] || return 1 + cmd="$( { tr '\0' ' ' < "/proc/$pid/cmdline"; } 2>/dev/null )" || return 1 + case "$cmd" in *"$mark"*) return 0 ;; *) return 1 ;; esac +} + +# pid_alive PID:当前服务的 pid 校验(svc.env pidcheck 优先;旧登记回退 agent-system)。 +pid_alive() { + local pid="$1" mark + mark="$(svc_get pidcheck)" + [ -z "$mark" ] && mark="agent-system" + pid_alive_mark "$pid" "$mark" +} + +read_pid() { [ -f "$ENV_PID" ] && cat "$ENV_PID" 2>/dev/null || true; } + +# proc_stop PID(agent/static):TERM 优雅 → KILL 兜底(postgres 不走此路径)。 +proc_stop() { + local pid="$1" kill -TERM "$pid" for _ in $(seq 1 100); do - pid_alive "$pid" || { c_ok "[$ENV_NAME] 已停止"; rm -f "$ENV_PID"; hist_event stop ok "$pid" "SIGTERM 优雅退出"; return 0; } + pid_alive "$pid" || return 0 sleep 0.1 done c_warn "[$ENV_NAME] 10s 未退出,SIGKILL 兜底" kill -KILL "$pid" 2>/dev/null || true - rm -f "$ENV_PID" - c_ok "[$ENV_NAME] 已强制停止" - hist_event stop ok "$pid" "SIGKILL 强制停止" + return 0 } port_owner() { @@ -334,16 +312,375 @@ port_owner() { fi } -do_build() { - c_info "[$ENV_NAME] 构建最新组合(make build:前端 + 内嵌后端)" - if ! make -C "$ROOT" build; then - c_err "[$ENV_NAME] 构建失败——旧服务已停止,本次部署中止(修复后重跑)" +check_port_free() { + local owner; owner="$(port_owner)" + if [ -n "$owner" ]; then + c_err "[$ENV_NAME] 端口被占用(非本服务):$owner" + c_err "处理:停掉该进程,或换端口(--port / --addr)" return 1 fi } -# seed_settings:把 CLI/环境变量给出的模型与搜索配置写入 settings(幂等; -# 只写明确给出的字段;设置持久化在环境数据目录——播种一次后续启动无需再传)。 +# ---- 服务配置解析(CLI > 配置文件/环境变量 > svc.env 持久化 > 默认)---- + +env_config() { + local name="$1" + valid_env "$name" || { + c_err "非法服务名:$name(^[a-z][a-z0-9-]{0,15}$;保留字 status/list/db/rm 等不可用)" + exit 2 + } + ENV_NAME="$name" + ENV_DIR="$RUNTIME/$name" + ENV_PID="$ENV_DIR/svc.pid" + ENV_LOG="$ENV_DIR/svc.log" + ENV_BIN_DIR="$ENV_DIR/bin" + ENV_BIN="$ENV_BIN_DIR/agent-system" + local registered="" + [ -f "$ENV_DIR/svc.env" ] && registered="$(svc_kind "$name")" + SVC_KIND="${registered:-agent}" + [ -n "$CLI_KIND" ] && SVC_KIND="$CLI_KIND" + # 类型与既有登记冲突防护:同名服务换类型必须先 rm。 + if [ -n "$registered" ] && [ -n "$CLI_KIND" ] && [ "$CLI_KIND" != "$registered" ]; then + c_err "[$ENV_NAME] 已登记为 kind=$registered,不能改类型;先 scripts/svc.sh rm $ENV_NAME" + exit 2 + fi + [ "$SVC_KIND" = postgres ] && ENV_LOG="$ENV_DIR/pg.log" + + local base_addr base_data persisted_addr + base_addr="$(env_var "$name" ADDR)" + base_data="$(env_var "$name" DATA)" + persisted_addr="" + [ -f "$ENV_DIR/svc.env" ] && persisted_addr="$(svc_get addr)" + if [ -z "$base_addr" ]; then + if [ -n "$persisted_addr" ]; then + base_addr="$persisted_addr" + else + case "$name" in + dev) base_addr="127.0.0.1:8788" ;; + prod) base_addr="127.0.0.1:8787" ;; + *) + local p + p="$(derive_port "$name")" || { + c_err "[$name] 无空闲派生端口(8800-9099),请用 --port/--addr 或 SVC_$(printf '%s' "$name" | tr 'a-z-' 'A-Z_')_ADDR 显式指定" + exit 1 + } + base_addr="127.0.0.1:$p" + ;; + esac + fi + fi + if [ -z "$base_data" ]; then + case "$SVC_KIND" in + postgres) base_data="$ENV_DIR/pgdata" ;; + *) base_data="$ENV_DIR/data" ;; + esac + fi + ENV_ADDR="$(addr_compose "$base_addr")" + ENV_DATA="${CLI_DATA:-$base_data}" + return 0 +} + +# agent_has_explicit_cfg:用户是否显式给了运行参数(决定是否复用持久化 exec=)。 +agent_has_explicit_cfg() { + [ -n "$CLI_ADDR$CLI_HOST$CLI_PORT$CLI_DSN$CLI_AUTH$CLI_PLUGINS$CLI_EXTRA" ] && return 0 + [ "$CLI_MOCK" = 1 ] && return 0 + [ -n "$SVC_ENVFILE" ] && return 0 + [ -n "$(env_var "$ENV_NAME" ADDR)$(env_var "$ENV_NAME" DATA)$(env_var "$ENV_NAME" DSN)$(env_var "$ENV_NAME" AUTH)$(env_var "$ENV_NAME" MOCK)" ] && return 0 + return 1 +} + +# adjust_args_for_bin BIN ARGS:按二进制 serve 用法过滤旧产物不认识的 --dsn/--auth。 +adjust_args_for_bin() { + local bin="$1" args="$2" out + out="$("$bin" serve -h 2>&1 || true)" + local filtered="" w drop_next=0 + for w in $args; do + case "$w" in + --dsn|--auth) + if printf '%s\n' "$out" | grep -q -- "$w"; then + filtered="$filtered $w" + else + drop_next=1 + fi + ;; + *) + if [ "$drop_next" = 1 ]; then drop_next=0; continue; fi + filtered="$filtered $w" + ;; + esac + done + printf '%s' "${filtered# }" +} + +# ---- 提交列举与选择(dev / release 两源)---- + +releases_root() { + [ -n "${REL_ROOT:-}" ] && return 0 + REL_ROOT="${SVC_RELEASES_DIR:-$ROOT/../Agent-System-Releases}" + if [ ! -d "$REL_ROOT/.git" ]; then + c_err "Releases 仓不存在:$REL_ROOT(可用 SVC_RELEASES_DIR 指定路径)" + exit 2 + fi +} + +deployed_envs() { + local key="$1" d e s out="" + for d in "$RUNTIME"/*/; do + [ -f "${d}svc.env" ] || continue + e="$(basename "$d")" + valid_env "$e" || continue + s="$(sed -n 's/^source=//p' "${d}svc.env")" + [ "$s" = "$key" ] && out="${out:+$out,}$e" + done + printf '%s' "$out" +} + +print_commit_list() { # SRC N PICK:提交表到 stdout;PICK=1 时填充 PICK_OPTS + local src="$1" count="$2" pick="$3" + local sep=$'\037' + if [ "$src" = dev ]; then + c_info "Dev 仓提交($ROOT;倒序取 $count 条)——部署:scripts/svc.sh deploy --from dev@" + printf ' %3s %-9s %-10s %s\n' '#' 'sha' '日期' '主题' + local head_short; head_short="$(git -C "$ROOT" rev-parse --short HEAD)" + local n=0 h date subject marks envs + while IFS="$sep" read -r h date subject; do + [ -z "$h" ] && continue + n=$((n + 1)) + marks="" + [ "$h" = "$head_short" ] && marks="${marks}[HEAD]" + envs="$(deployed_envs "dev@$h")" + [ -n "$envs" ] && marks="${marks}[已部署:$envs]" + printf ' %3d %-9s %-10s %s %s\n' "$n" "$h" "$date" "$subject" "$marks" + [ "$pick" = 1 ] && PICK_OPTS+=("$h") + done < <(git -C "$ROOT" log -n "$count" --format="%h${sep}%ad${sep}%s" --date=short) + else + c_info "Releases 仓提交($REL_ROOT;倒序取 $count 条)——部署:scripts/svc.sh deploy --from release@<版本>" + printf ' %3s %-9s %-10s %-8s %-9s %s\n' '#' 'sha' '日期' '版本' 'dev提交' '主题' + local head_full; head_full="$(git -C "$REL_ROOT" rev-parse HEAD)" + local n=0 full h date subject ver dc marks envs + while IFS="$sep" read -r full h date subject; do + [ -z "$full" ] && continue + n=$((n + 1)) + ver="$(git -C "$REL_ROOT" ls-tree --name-only "$full:versions" 2>/dev/null | sort -V | tail -1 || true)" + dc="" + [ -n "$ver" ] && dc="$(git -C "$REL_ROOT" show "$full:versions/$ver/manifest.json" 2>/dev/null | sed -n 's/.*"devCommit": *"\([0-9a-f]\{8,\}\)".*/\1/p' || true)" + [ -z "$ver" ] && ver="-" + marks="" + [ "$full" = "$head_full" ] && marks="${marks}[HEAD]" + envs="$(deployed_envs "release@$ver")" + [ -n "$envs" ] && marks="${marks}[已部署:$envs]" + printf ' %3d %-9s %-10s %-8s %-9s %s %s\n' "$n" "$h" "$date" "$ver" "${dc:0:8}" "$subject" "$marks" + [ "$pick" = 1 ] && PICK_OPTS+=("$ver") + done < <(git -C "$REL_ROOT" log -n "$count" --format="%H${sep}%h${sep}%ad${sep}%s" --date=short) + fi + return 0 +} + +cmd_commits() { + local src="${1:-dev}" count="${2:-20}" + case "$src" in + dev|release) ;; + *) c_err "用法:scripts/svc.sh commits [dev|release] [N]"; exit 2 ;; + esac + case "$count" in ''|*[!0-9]*|0) c_err "N 须为正整数"; exit 2 ;; esac + [ "$src" = release ] && releases_root + print_commit_list "$src" "$count" 0 +} + +pick_commit() { # SRC → stdout 只回显 "dev@"/"release@<版本>"(列表走 stderr) + local src="$1" + [ -t 0 ] || { c_err "--pick 需要交互终端;非交互环境请用 --from $src@"; exit 2; } + PICK_OPTS=() + print_commit_list "$src" 20 1 >&2 + local sel + printf '\n输入序号选择要部署的提交(1-%s,回车取消):' "${#PICK_OPTS[@]}" >&2 + read -r sel || { printf '\n' >&2; c_info "已取消"; exit 0; } + case "$sel" in + ''|*[!0-9]*) c_info "已取消(未选择)"; exit 0 ;; + *) [ "$sel" -ge 1 ] && [ "$sel" -le "${#PICK_OPTS[@]}" ] || { c_err "序号超范围:$sel"; exit 2; } ;; + esac + echo "$src@${PICK_OPTS[$((sel - 1))]}" +} + +# ---- 部署来源解析与物化 ---- + +resolve_release() { # REF(空=最新) → REL_COMMIT/REL_VERSION/SRC_DEVCOMMIT + local ref="${1:-}" commit ver + if [ -z "$ref" ]; then + commit="$(git -C "$REL_ROOT" rev-parse --verify --quiet HEAD)" + [ -n "$commit" ] || { c_err "Releases 仓无法解析 HEAD:$REL_ROOT"; exit 2; } + elif commit="$(git -C "$REL_ROOT" rev-parse --verify --quiet "${ref}^{commit}")"; then + : + elif git -C "$REL_ROOT" cat-file -e "HEAD:versions/$ref/manifest.json" 2>/dev/null; then + commit="$(git -C "$REL_ROOT" rev-parse HEAD)" + else + c_err "Releases 仓无此引用:$ref(可用版本号或提交号;先看可用项:scripts/svc.sh commits release)" + exit 2 + fi + ver="$(git -C "$REL_ROOT" ls-tree --name-only "$commit:versions" 2>/dev/null | sort -V | tail -1 || true)" + [ -n "$ver" ] || { c_err "提交 $(git -C "$REL_ROOT" rev-parse --short "$commit") 下无 versions/ 快照,无法确定版本号"; exit 2; } + REL_COMMIT="$commit"; REL_VERSION="$ver" + SRC_DEVCOMMIT="$(git -C "$REL_ROOT" show "$commit:versions/$ver/manifest.json" 2>/dev/null | sed -n 's/.*"devCommit": *"\([0-9a-f]\{8,\}\)".*/\1/p' || true)" +} + +resolve_source() { # → SRC_KINDSRC(local|dev|release) + SRC_LABEL(+SRC_FULL/SRC_SHA/REL_*) + local spec="${CLI_FROM:-local}" + if [ -n "$CLI_PICK" ]; then + [ "$CLI_PICK" = release ] && releases_root + spec="$(pick_commit "$CLI_PICK")" + fi + case "$spec" in + local) + SRC_KINDSRC=local + SRC_LABEL="local@$(git -C "$ROOT" rev-parse --short HEAD)" + ;; + dev|dev@*) + local ref="HEAD" + case "$spec" in dev@*) ref="${spec#dev@}" ;; esac + local full + full="$(git -C "$ROOT" rev-parse --verify --quiet "${ref}^{commit}")" || { + c_err "Dev 仓无此提交:$ref(若是远端新提交,先 git -C $ROOT fetch;可用项:scripts/svc.sh commits dev)" + exit 2 + } + SRC_KINDSRC=dev + SRC_FULL="$full" + SRC_SHA="$(git -C "$ROOT" rev-parse --short "$full")" + SRC_LABEL="dev@$SRC_SHA" + ;; + release|release@*) + releases_root + local ref="" + case "$spec" in release@*) ref="${spec#release@}" ;; esac + resolve_release "$ref" + SRC_KINDSRC=release + SRC_LABEL="release@$REL_VERSION" + ;; + *) + c_err "无法解析 --from $spec(可用:local | dev[@] | release[@<版本|sha>])" + exit 2 + ;; + esac +} + +install_artifacts() { # SRCBIN SRCPLUGINS:agent 产物复制进服务目录。 + local srcbin="$1" srcplug="$2" + mkdir -p "$ENV_BIN_DIR" + rm -rf "$ENV_DIR/plugins" + cp -f "$srcbin" "$ENV_BIN" || return 1 + if [ -d "$srcplug" ]; then + cp -r "$srcplug" "$ENV_DIR/plugins" || return 1 + else + c_warn "[$ENV_NAME] 源插件目录缺失:$srcplug(按空插件集部署)" + mkdir -p "$ENV_DIR/plugins" + fi + c_ok "[$ENV_NAME] 产物就位:$ENV_BIN(plugins:$ENV_DIR/plugins)" +} + +# materialize_dev_commit FULL SHA TARGET:隔离 worktree 物化+构建;stdout 只回显 worktree +# 路径(提示信息走 stderr)。TARGET=build(agent)| frontend(static)。 +materialize_dev_commit() { + local full="$1" sha="$2" target="$3" + local wt="$RUNTIME/.worktrees/$sha" + if [ -d "$wt" ]; then + c_info "[$ENV_NAME] 复用已有构建 worktree:$wt" + else + c_info "[$ENV_NAME] 物化 Dev 提交 $sha → 隔离 worktree(不触碰当前工作区)" + git -C "$ROOT" worktree add --detach "$wt" "$full" >/dev/null || return 1 + fi + c_info "[$ENV_NAME] 物化子模块组合(git submodule update --init --recursive;缺对象时需网络)" + git -C "$wt" submodule update --init --recursive || { + c_err "[$ENV_NAME] 子模块物化失败(对象缺失时先 git -C $ROOT fetch origin)" + return 1 + } + c_info "[$ENV_NAME] 构建(make $target:npm ci + 构建,需网络)" + make -C "$wt" "$target" || return 1 + echo "$wt" +} + +# recycle_worktree SHA:回收构建 worktree(尽力而为)。 +recycle_worktree() { + local wt="$RUNTIME/.worktrees/$1" + c_info "[$ENV_NAME] 回收构建 worktree" + git -C "$ROOT" worktree remove --force "$wt" >/dev/null 2>&1 \ + || c_warn "[$ENV_NAME] worktree 回收失败,可手动:git -C $ROOT worktree remove --force $wt" +} + +materialize_release() { + local art="$REL_ROOT/build/$REL_VERSION" bin="$REL_ROOT/build/$REL_VERSION/agent-system" + local mf="$REL_ROOT/versions/$REL_VERSION/manifest.json" + if [ ! -f "$bin" ]; then + c_err "[$ENV_NAME] Releases 仓无制品:$bin" + c_err "先在 Releases 仓构建:scripts/build-release.sh $REL_VERSION(或换版本:scripts/svc.sh commits release)" + return 1 + fi + if [ -f "$mf" ]; then + local want have + want="$(sed -n 's/.*"agent-system": *"\([0-9a-f]\{64\}\)".*/\1/p' "$mf" | head -1)" + have="$(sha256sum "$bin" | cut -d' ' -f1)" + if [ -n "$want" ] && [ "$want" != "$have" ]; then + c_err "[$ENV_NAME] sha256 校验失败:制品与 manifest 不符($bin)——拒绝部署" + return 1 + fi + [ -n "$want" ] && c_ok "[$ENV_NAME] sha256 校验通过(manifest:$REL_VERSION)" + else + c_warn "[$ENV_NAME] 无 manifest 可校验:$mf" + fi + [ -n "${SRC_DEVCOMMIT:-}" ] && c_info "[$ENV_NAME] 版本对应 Dev 提交:$SRC_DEVCOMMIT" + install_artifacts "$bin" "$art/plugins" || return 1 +} + +# ---- agent:部署与启动 ---- + +agent_do_deploy() { + resolve_source + if [ "$CLI_DRYRUN" = 1 ]; then print_agent_plan; return 0; fi + hist_event deploy begin "$(read_pid)" "from=$SRC_LABEL" + do_stop_quiet + local wt + case "$SRC_KINDSRC" in + local) + c_info "[$ENV_NAME] 构建当前工作区(make build)——唯一写主检出构建产物的路径" + make -C "$ROOT" build || { hist_event deploy fail "$(read_pid)" "构建失败($SRC_LABEL)"; return 1; } + install_artifacts "$BIN" "$ROOT/plugins" \ + || { hist_event deploy fail "$(read_pid)" "产物复制失败"; return 1; } + ;; + dev) + wt="$(materialize_dev_commit "$SRC_FULL" "$SRC_SHA" build)" \ + || { hist_event deploy fail "$(read_pid)" "物化/构建失败($SRC_LABEL)"; return 1; } + install_artifacts "$wt/bin/agent-system" "$wt/plugins" \ + || { hist_event deploy fail "$(read_pid)" "产物复制失败"; return 1; } + recycle_worktree "$SRC_SHA" + ;; + release) + materialize_release || { hist_event deploy fail "$(read_pid)" "物化/校验失败($SRC_LABEL)"; return 1; } + ;; + esac + agent_do_start || { hist_event deploy fail "$(read_pid)" "启动失败($SRC_LABEL)"; return 1; } + hist_event deploy ok "$(read_pid)" "from=$SRC_LABEL" + return 0 +} + +print_agent_plan() { + c_info "[$ENV_NAME] 部署计划(dry-run,不执行)" + printf ' 服务:%s(kind=agent)→ %s(addr %s,data %s)\n' "$ENV_NAME" "$ENV_DIR" "$ENV_ADDR" "$ENV_DATA" + case "$SRC_KINDSRC" in + local) + printf ' 来源:%s(make build 当前工作区;唯一写主检出构建产物的路径)\n' "$SRC_LABEL" + printf ' 产物:%s ← %s + %s/plugins\n' "$ENV_BIN" "$BIN" "$ROOT" ;; + dev) + printf ' 来源:%s(完整提交 %s)\n' "$SRC_LABEL" "$SRC_FULL" + printf ' 物化:worktree %s(submodule update --init --recursive + make build)\n' "$RUNTIME/.worktrees/$SRC_SHA" ;; + release) + printf ' 来源:%s(Releases 提交 %s)\n' "$SRC_LABEL" "$(git -C "$REL_ROOT" rev-parse --short "$REL_COMMIT")" + [ -n "${SRC_DEVCOMMIT:-}" ] && printf ' 对应 Dev 提交:%s\n' "$SRC_DEVCOMMIT" + local art="$REL_ROOT/build/$REL_VERSION/agent-system" + if [ -f "$art" ]; then printf ' 制品:%s(存在,将做 sha256 校验)\n' "$art" + else printf ' 制品:%s(缺失!需先在 Releases 仓构建,或换版本)\n' "$art"; fi ;; + esac + return 0 +} + +# seed_settings:把 CLI/环境变量给出的模型与搜索配置写入 settings(幂等)。 seed_settings() { local host="$1" port="$2" local cb="${CLI_MBASE:-${SVC_MODEL_BASE:-}}" ck="${CLI_MKEY:-${SVC_MODEL_KEY:-}}" cn="${CLI_MNAME:-${SVC_MODEL_NAME:-}}" @@ -384,402 +721,540 @@ PY fi } -# adjust_args_for_bin BIN ARGS:按目标二进制的 serve 用法过滤参数——旧版本产物可能 -# 不认识 --dsn/--auth(探测 serve -h 输出);不支持的 flag 连同其值一并省略。 -adjust_args_for_bin() { - local bin="$1" args="$2" out - out="$("$bin" serve -h 2>&1 || true)" - local filtered="" w drop_next=0 - for w in $args; do - case "$w" in - --dsn|--auth) - if printf '%s\n' "$out" | grep -q -- "$w"; then - filtered="$filtered $w" - else - drop_next=1 - fi - ;; - *) - if [ "$drop_next" = 1 ]; then drop_next=0; continue; fi - filtered="$filtered $w" - ;; - esac - done - printf '%s' "${filtered# }" -} - -do_start() { - if pid_alive "$(read_pid)"; then - c_warn "[$ENV_NAME] 已在运行 pid=$(read_pid)(如需换参数/新版本请用 deploy/restart)" - return 0 - fi +agent_do_start() { mkdir -p "$ENV_DATA" "$ENV_DIR" - # 端口占用预检:他人现场不动,只报告。 - local owner; owner="$(port_owner)" - if [ -n "$owner" ]; then - c_err "[$ENV_NAME] 端口被占用(非本脚本管理):$owner" - c_err "处理:停掉该进程,或换端口(--port 8799 / --addr host:port)" - return 1 - fi + 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[@]|release[@<版本>]]" return 1 fi SVC_VERSION="$("$ENV_BIN" version 2>/dev/null || true)" - if [ -n "$ENV_ARGS" ]; then - local adjusted; adjusted="$(adjust_args_for_bin "$ENV_BIN" "$ENV_ARGS")" - if [ "$adjusted" != "$ENV_ARGS" ]; then - c_warn "[$ENV_NAME] 该产物不支持 --dsn/--auth(旧版本二进制),已自动省略:$ENV_ARGS → ${adjusted:-<无>}" + # 组装启动参数:显式配置 → 重建;否则复用上次持久化 exec=。 + local exec_args + if ! agent_has_explicit_cfg && [ -n "$(svc_get exec)" ]; then + exec_args="$(svc_get exec)" + c_info "[$ENV_NAME] 复用上次配置(svc.env exec=;显式参数可覆盖):$exec_args" + else + local args="" + if [ "$CLI_MOCK" = 1 ] || [ "$(env_var "$ENV_NAME" MOCK)" = "1" ]; then + args="--mock" + elif [ "$ENV_NAME" != dev ]; then + local dsn="${CLI_DSN:-$(env_var "$ENV_NAME" DSN)}" + [ -n "$dsn" ] && args="$args --dsn $dsn" + local auth="${CLI_AUTH:-$(env_var "$ENV_NAME" AUTH)}" + [ -z "$auth" ] && auth=auto + args="$args --auth $auth" fi - ENV_ARGS="$adjusted" + local adjusted; adjusted="$(adjust_args_for_bin "$ENV_BIN" "$args")" + if [ "$adjusted" != "$args" ]; then + c_warn "[$ENV_NAME] 该产物不支持 --dsn/--auth(旧版本二进制),已自动省略:${args:-<无>} → ${adjusted:-<无>}" + fi + args="$adjusted" + exec_args="--addr $ENV_ADDR --data $ENV_DATA --plugins ${CLI_PLUGINS:-$ENV_DIR/plugins} $args $CLI_EXTRA" + exec_args="$(printf '%s' "$exec_args" | sed 's/[[:space:]]*$//')" fi - c_info "[$ENV_NAME] 启动:$ENV_BIN serve --addr $ENV_ADDR --data $ENV_DATA --plugins $ENV_PLUGINS $ENV_ARGS $CLI_EXTRA" - # 直接 nohup 简单命令(不经 cd 复合后台作业):$! 即服务进程本身,pidfile 语义才成立。 - nohup "$ENV_BIN" serve --addr "$ENV_ADDR" --data "$ENV_DATA" --plugins "$ENV_PLUGINS" $ENV_ARGS $CLI_EXTRA \ - >> "$ENV_LOG" 2>&1 & + SVC_EXEC="$exec_args" + SVC_PIDCHECK="$ENV_BIN" + c_info "[$ENV_NAME] 启动:$ENV_BIN serve $exec_args" + # 直接 nohup 简单命令:$! 即服务进程本身,pidfile 语义才成立。 + nohup "$ENV_BIN" serve $exec_args >> "$ENV_DIR/svc.log" 2>&1 & echo $! > "$ENV_PID" record_state - # 健康等待:最多 20s;探测主机取绑定主机(0.0.0.0/:: → 127.0.0.1)。 - local host="${ENV_ADDR%:*}" port="${ENV_ADDR##*:}" - if [ "$host" = "0.0.0.0" ] || [ "$host" = "::" ]; then host="127.0.0.1"; fi + # 健康探测:取 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://$host:$port/" 2>/dev/null; then - c_ok "[$ENV_NAME] 已启动 pid=$(cat "$ENV_PID") → http://$host:$port(来源 ${SVC_SOURCE:-?},日志:$ENV_LOG)" - hist_event start ok "$(read_pid)" "http://$host:$port" - seed_settings "$host" "$port" + if curl -s -o /dev/null -m 2 "http://$phost:${probe##*:}/" 2>/dev/null; then + c_ok "[$ENV_NAME] 已启动 pid=$(read_pid) → http://$phost:${probe##*:}(来源 ${SVC_SOURCE:-?},日志:$ENV_DIR/svc.log)" + hist_event start ok "$(read_pid)" "http://$phost:${probe##*:}" + seed_settings "$phost" "${probe##*:}" return 0 fi pid_alive "$(read_pid)" || { - c_err "[$ENV_NAME] 进程启动后即退出,查看日志:tail -40 $ENV_LOG" - hist_event start fail "$(read_pid)" "进程即退(日志 $ENV_LOG)" + c_err "[$ENV_NAME] 进程启动后即退出,查看日志:tail -40 $ENV_DIR/svc.log" + hist_event start fail "$(read_pid)" "进程即退(日志 svc.log)" return 1 } sleep 0.2 done - c_err "[$ENV_NAME] 20s 健康检查未通过(进程存活但未响应),查看日志:tail -40 $ENV_LOG" + c_err "[$ENV_NAME] 20s 健康检查未通过(进程存活但未响应),查看日志:tail -40 $ENV_DIR/svc.log" hist_event start fail "$(read_pid)" "健康检查超时" return 1 } -# ---- 提交列举与选择 ---- +# ---- static:前端静态服务 ---- -# releases_root:定位 Releases 仓(SVC_RELEASES_DIR > ../Agent-System-Releases)。 -releases_root() { - [ -n "${REL_ROOT:-}" ] && return 0 - REL_ROOT="${SVC_RELEASES_DIR:-$ROOT/../Agent-System-Releases}" - if [ ! -d "$REL_ROOT/.git" ]; then - c_err "Releases 仓不存在:$REL_ROOT(可用 SVC_RELEASES_DIR 指定路径)" - exit 2 +static_do_deploy() { + # --dir:登记既有目录(不构建);否则 dev@sha/local 构建 frontend dist → htdocs/。 + if [ -n "$CLI_DIR" ]; then + [ -d "$CLI_DIR" ] || { c_err "[$ENV_NAME] --dir 目录不存在:$CLI_DIR"; return 1; } + resolve_dir_deploy + return 0 fi + resolve_source + if [ "$CLI_DRYRUN" = 1 ]; then print_static_plan; return 0; fi + hist_event deploy begin "$(read_pid)" "from=$SRC_LABEL" + do_stop_quiet + local src_dist wt + case "$SRC_KINDSRC" in + local) + c_info "[$ENV_NAME] 构建当前工作区前端(make frontend)——写主检出构建产物(gitignore 目录)" + make -C "$ROOT" frontend || { hist_event deploy fail "$(read_pid)" "make frontend 失败"; return 1; } + src_dist="$ROOT/modules/frontend/dist" + ;; + dev) + wt="$(materialize_dev_commit "$SRC_FULL" "$SRC_SHA" frontend)" \ + || { hist_event deploy fail "$(read_pid)" "物化/构建失败($SRC_LABEL)"; return 1; } + src_dist="$wt/modules/frontend/dist" + recycle_worktree "$SRC_SHA" + ;; + release) + c_err "[$ENV_NAME] release 制品内嵌前端(无独立 dist);static 请用 --from dev|local 或 --dir" + hist_event deploy fail "$(read_pid)" "release 源不支持 static" + return 1 + ;; + esac + [ -d "$src_dist" ] || { c_err "[$ENV_NAME] 前端产物缺失:$src_dist"; hist_event deploy fail "$(read_pid)" "dist 缺失"; return 1; } + mkdir -p "$ENV_DIR" + rm -rf "${ENV_DIR:?}/htdocs" + cp -r "$src_dist" "$ENV_DIR/htdocs" || { hist_event deploy fail "$(read_pid)" "dist 复制失败"; return 1; } + write_static_reg "$ENV_DIR/htdocs" + c_ok "[$ENV_NAME] 前端产物就位:$ENV_DIR/htdocs(源 $SRC_LABEL)" + hist_event deploy ok "$(read_pid)" "from=$SRC_LABEL" + return 0 } -# deployed_envs KEY:source=KEY 的环境列表("a,b"),从各环境 svc.env 汇总。 -deployed_envs() { - local key="$1" d e s out="" - for d in "$RUNTIME"/*/; do - [ -f "${d}svc.env" ] || continue - e="$(basename "$d")" - valid_env "$e" || continue - s="$(sed -n 's/^source=//p' "${d}svc.env")" - [ "$s" = "$key" ] && out="${out:+$out,}$e" - done - printf '%s' "$out" +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 } -# print_commit_list SRC N PICK:列出提交表;PICK=1 时同时填充 PICK_OPTS(供 --pick)。 -print_commit_list() { - local src="$1" count="$2" pick="$3" - local sep=$'\037' - if [ "$src" = dev ]; then - c_info "Dev 仓提交($ROOT;倒序取 $count 条)——部署:scripts/svc.sh deploy --from dev@" - printf ' %3s %-9s %-10s %s\n' '#' 'sha' '日期' '主题' - local head_short; head_short="$(git -C "$ROOT" rev-parse --short HEAD)" - local n=0 h date subject marks envs - while IFS="$sep" read -r h date subject; do - [ -z "$h" ] && continue - n=$((n + 1)) - marks="" - [ "$h" = "$head_short" ] && marks="${marks}[HEAD]" - envs="$(deployed_envs "dev@$h")" - [ -n "$envs" ] && marks="${marks}[已部署:$envs]" - printf ' %3d %-9s %-10s %s %s\n' "$n" "$h" "$date" "$subject" "$marks" - [ "$pick" = 1 ] && PICK_OPTS+=("$h") - done < <(git -C "$ROOT" log -n "$count" --format="%h${sep}%ad${sep}%s" --date=short) +# write_static_reg HTDOCS:登记/更新 static 服务(保留 pid 等当前态由 start 覆写)。 +write_static_reg() { + local htdocs="$1" + local old_pid=""; [ -f "$ENV_PID" ] && old_pid="$(read_pid)" + local merged_tags; merged_tags="$(svc_merge_tags)" # 先读旧文件再截断 + mkdir -p "$ENV_DIR" + { + printf 'kind=static\n' + printf 'env=%s\n' "$ENV_NAME" + printf 'pid=%s\n' "${old_pid:-}" + printf 'addr=%s\n' "$ENV_ADDR" + printf 'tags=%s\n' "$merged_tags" + printf 'source=%s\n' "$SRC_LABEL" + printf 'started=%s\n' "$(date -Is)" + printf 'htdocs=%s\n' "$htdocs" + } > "$ENV_DIR/svc.env" + chmod 600 "$ENV_DIR/svc.env" +} + +print_static_plan() { + c_info "[$ENV_NAME] 部署计划(dry-run,不执行)" + printf ' 服务:%s(kind=static)→ %s(addr %s)\n' "$ENV_NAME" "$ENV_DIR" "$ENV_ADDR" + if [ -n "$CLI_DIR" ]; then + printf ' 目录:%s(--dir 直接服务,不构建)\n' "$CLI_DIR" else - c_info "Releases 仓提交($REL_ROOT;倒序取 $count 条)——部署:scripts/svc.sh 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) + case "$SRC_KINDSRC" in + local) printf ' 来源:%s(make frontend → htdocs/)\n' "$SRC_LABEL" ;; + dev) printf ' 来源:%s(完整提交 %s)\n 物化:worktree %s(make frontend → htdocs/)\n' \ + "$SRC_LABEL" "$SRC_FULL" "$RUNTIME/.worktrees/$SRC_SHA" ;; + release) printf ' 来源:release —— 不支持(制品内嵌前端);请用 dev|local 或 --dir\n' ;; + esac fi return 0 } -cmd_commits() { - local src="${1:-dev}" count="${2:-20}" - case "$src" in - dev|release) ;; - *) c_err "用法:scripts/svc.sh commits [dev|release] [N]"; exit 2 ;; - esac - case "$count" in ''|*[!0-9]*|0) c_err "N 须为正整数"; exit 2 ;; esac - [ "$src" = release ] && releases_root - print_commit_list "$src" "$count" 0 +static_do_start() { + mkdir -p "$ENV_DIR" + check_port_free || return 1 + command -v python3 >/dev/null 2>&1 || { c_err "[$ENV_NAME] static 需要 python3"; return 1; } + local htdocs; htdocs="$(svc_get htdocs)" + [ -n "$htdocs" ] || htdocs="$ENV_DIR/htdocs" + [ -d "$htdocs" ] || { c_err "[$ENV_NAME] 静态目录缺失:$htdocs(先 deploy 或 --dir)"; return 1; } + local host="${ENV_ADDR%:*}" port="${ENV_ADDR##*:}" + SVC_EXEC="--directory $htdocs" + SVC_PIDCHECK="--directory $htdocs" + SVC_VERSION="" + c_info "[$ENV_NAME] 启动静态服务:python3 http.server $port --bind $host --directory $htdocs" + nohup python3 -m http.server "$port" --bind "$host" --directory "$htdocs" >> "$ENV_DIR/svc.log" 2>&1 & + echo $! > "$ENV_PID" + record_state "htdocs=$htdocs" + local probe_host="$host" + [ "$probe_host" = "0.0.0.0" ] || [ "$probe_host" = "::" ] && probe_host="127.0.0.1" + for _ in $(seq 1 50); do + if curl -s -o /dev/null -m 2 "http://$probe_host:$port/" 2>/dev/null; then + c_ok "[$ENV_NAME] 已启动 pid=$(read_pid) → http://$probe_host:$port/(目录 $htdocs)" + hist_event start ok "$(read_pid)" "http://$probe_host:$port/" + return 0 + fi + pid_alive "$(read_pid)" || { + c_err "[$ENV_NAME] 进程启动后即退出,查看日志:tail -40 $ENV_DIR/svc.log" + hist_event start fail "$(read_pid)" "进程即退" + return 1 + } + sleep 0.2 + done + c_err "[$ENV_NAME] 10s 健康检查未通过" + hist_event start fail "$(read_pid)" "健康检查超时" + return 1 } -# pick_commit SRC:交互式编号选择,stdout 只回显 "dev@" / "release@<版本>" -# (列表与提示走 stderr,避免被调用方的命令替换捕获);取消则退出 0。 -pick_commit() { - local src="$1" - [ -t 0 ] || { c_err "--pick 需要交互终端;非交互环境请用 --from $src@"; 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))]}" +# ---- postgres:数据库服务 ---- + +pg_bindir() { + if [ -n "${SVC_PG_BIN:-}" ] && [ -x "${SVC_PG_BIN}/initdb" ]; then printf '%s' "$SVC_PG_BIN"; return 0; fi + local d + d="$(pg_config --bindir 2>/dev/null || true)" + [ -n "$d" ] && [ -x "$d/initdb" ] && { printf '%s' "$d"; return 0; } + for d in /usr/lib/postgresql/*/bin /usr/local/pgsql/bin; do + [ -x "$d/initdb" ] && { printf '%s' "$d"; return 0; } + done + return 1 } -# ---- 部署来源解析与物化 ---- +cmd_db() { + local sub="${1:-}" + [ $# -gt 0 ] && shift + case "$sub" in + create) db_create "$@" ;; + dsn) db_dsn "$@" ;; + *) c_err "用法:scripts/svc.sh db create [--port --user --password --db --auth-mode --tag] | db dsn [--reveal] [--db X]"; exit 2 ;; + esac +} -# resolve_release REF:解析 Releases 引用(空 = 最新;版本号 / 提交号 / tag), -# 设 REL_COMMIT / REL_VERSION,并顺手取 manifest 里的 devCommit 到 SRC_DEVCOMMIT。 -resolve_release() { - local ref="${1:-}" commit ver - if [ -z "$ref" ]; then - commit="$(git -C "$REL_ROOT" rev-parse --verify --quiet HEAD)" - [ -n "$commit" ] || { c_err "Releases 仓无法解析 HEAD:$REL_ROOT"; exit 2; } - elif commit="$(git -C "$REL_ROOT" rev-parse --verify --quiet "${ref}^{commit}")"; then - : - elif git -C "$REL_ROOT" cat-file -e "HEAD:versions/$ref/manifest.json" 2>/dev/null; then - commit="$(git -C "$REL_ROOT" rev-parse HEAD)" - else - c_err "Releases 仓无此引用:$ref(可用版本号或提交号;先看可用项:scripts/svc.sh commits release)" +db_create() { + local name="${1:-}" + [ -n "$name" ] || { c_err "用法:scripts/svc.sh db create [...]"; 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 - ver="$(git -C "$REL_ROOT" ls-tree --name-only "$commit:versions" 2>/dev/null | sort -V | tail -1 || true)" - [ -n "$ver" ] || { c_err "提交 $(git -C "$REL_ROOT" rev-parse --short "$commit") 下无 versions/ 快照,无法确定版本号"; exit 2; } - REL_COMMIT="$commit"; REL_VERSION="$ver" - SRC_DEVCOMMIT="$(git -C "$REL_ROOT" show "$commit:versions/$ver/manifest.json" 2>/dev/null | sed -n 's/.*"devCommit": *"\([0-9a-f]\{8,\}\)".*/\1/p' || true)" -} - -# resolve_source:解析 --from / --pick → SRC_KIND(local|dev|release) 与统一标签 -# SRC_LABEL(local@ | dev@ | release@<版本>,进 svc.env/history)。 -resolve_source() { - local spec="${CLI_FROM:-local}" - if [ -n "$CLI_PICK" ]; then - [ "$CLI_PICK" = release ] && releases_root - spec="$(pick_commit "$CLI_PICK")" + local pgbin + pgbin="$(pg_bindir)" || { c_err "找不到 postgres 工具链(initdb/pg_ctl);安装 postgresql 或设 SVC_PG_BIN="; 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 - case "$spec" in - local) - SRC_KIND=local - SRC_LABEL="local@$(git -C "$ROOT" rev-parse --short HEAD)" - ;; - dev|dev@*) - local ref="HEAD" - case "$spec" in dev@*) ref="${spec#dev@}" ;; esac - local full - full="$(git -C "$ROOT" rev-parse --verify --quiet "${ref}^{commit}")" || { - c_err "Dev 仓无此提交:$ref(若是远端新提交,先 git -C $ROOT fetch;可用项:scripts/svc.sh commits dev)" - exit 2 - } - SRC_KIND=dev - SRC_FULL="$full" - SRC_SHA="$(git -C "$ROOT" rev-parse --short "$full")" - SRC_LABEL="dev@$SRC_SHA" - ;; - release|release@*) - releases_root - local ref="" - case "$spec" in release@*) ref="${spec#release@}" ;; esac - resolve_release "$ref" - SRC_KIND=release - SRC_LABEL="release@$REL_VERSION" - ;; - *) - c_err "无法解析 --from $spec(可用:local | dev[@] | release[@<版本|sha>])" - exit 2 - ;; - esac -} - -# install_artifacts SRCBIN SRCPLUGINS:把构建产物复制进环境目录(环境自包含)。 -install_artifacts() { - local srcbin="$1" srcplug="$2" - mkdir -p "$ENV_BIN_DIR" - rm -rf "$ENV_DIR/plugins" - cp -f "$srcbin" "$ENV_BIN" || return 1 - if [ -d "$srcplug" ]; then - cp -r "$srcplug" "$ENV_DIR/plugins" || return 1 - else - c_warn "[$ENV_NAME] 源插件目录缺失:$srcplug(按空插件集部署)" - mkdir -p "$ENV_DIR/plugins" - fi - c_ok "[$ENV_NAME] 产物就位:$ENV_BIN(plugins:$ENV_DIR/plugins)" -} - -materialize_local() { - c_info "[$ENV_NAME] 构建当前工作区(make build)并复制到环境目录" - do_build || return 1 - install_artifacts "$BIN" "$ROOT/plugins" -} - -materialize_dev_commit() { - local wt="$RUNTIME/.worktrees/$SRC_SHA" - if [ -d "$wt" ]; then - c_info "[$ENV_NAME] 复用已有构建 worktree:$wt" - else - c_info "[$ENV_NAME] 物化 Dev 提交 $SRC_SHA → 隔离 worktree(不触碰当前工作区)" - git -C "$ROOT" worktree add --detach "$wt" "$SRC_FULL" >/dev/null || return 1 - fi - c_info "[$ENV_NAME] 物化子模块组合(git submodule update --init --recursive;缺对象时需网络)" - git -C "$wt" submodule update --init --recursive || { - c_err "[$ENV_NAME] 子模块物化失败(对象缺失时先 git -C $ROOT fetch origin)" - return 1 - } - c_info "[$ENV_NAME] 构建(make build:npm ci + go build,需网络)" - make -C "$wt" build || return 1 - install_artifacts "$wt/bin/agent-system" "$wt/plugins" || return 1 - c_info "[$ENV_NAME] 回收构建 worktree" - git -C "$ROOT" worktree remove --force "$wt" >/dev/null 2>&1 \ - || c_warn "[$ENV_NAME] worktree 回收失败,可手动:git -C $ROOT worktree remove --force $wt" -} - -materialize_release() { - local art="$REL_ROOT/build/$REL_VERSION" bin="$REL_ROOT/build/$REL_VERSION/agent-system" - local mf="$REL_ROOT/versions/$REL_VERSION/manifest.json" - if [ ! -f "$bin" ]; then - c_err "[$ENV_NAME] Releases 仓无制品:$bin" - c_err "先在 Releases 仓构建:scripts/build-release.sh $REL_VERSION(或换版本:scripts/svc.sh commits release)" - return 1 - fi - if [ -f "$mf" ]; then - local want have - want="$(sed -n 's/.*"agent-system": *"\([0-9a-f]\{64\}\)".*/\1/p' "$mf" | head -1)" - have="$(sha256sum "$bin" | cut -d' ' -f1)" - if [ -n "$want" ] && [ "$want" != "$have" ]; then - c_err "[$ENV_NAME] sha256 校验失败:制品与 manifest 不符($bin)——拒绝部署" - return 1 - fi - [ -n "$want" ] && c_ok "[$ENV_NAME] sha256 校验通过(manifest:$REL_VERSION)" - else - c_warn "[$ENV_NAME] 无 manifest 可校验:$mf" - fi - [ -n "${SRC_DEVCOMMIT:-}" ] && c_info "[$ENV_NAME] 版本对应 Dev 提交:$SRC_DEVCOMMIT" - install_artifacts "$bin" "$art/plugins" || return 1 -} - -materialize_source() { - case "$SRC_KIND" in - local) materialize_local ;; - dev) materialize_dev_commit ;; - release) materialize_release ;; - esac -} - -print_deploy_plan() { - c_info "[$ENV_NAME] 部署计划(dry-run,不执行)" - printf ' 环境:%s → %s(addr %s,data %s)\n' "$ENV_NAME" "$ENV_DIR" "$ENV_ADDR" "$ENV_DATA" - case "$SRC_KIND" in - local) - printf ' 来源:%s(构建当前工作区 make build)\n' "$SRC_LABEL" - printf ' 产物:%s ← %s + %s/plugins\n' "$ENV_BIN" "$BIN" "$ROOT" ;; - dev) - printf ' 来源:%s(完整提交 %s)\n' "$SRC_LABEL" "$SRC_FULL" - printf ' 物化:worktree %s(submodule update --init --recursive + make build)\n' "$RUNTIME/.worktrees/$SRC_SHA" - printf ' 产物:%s ← 该 worktree 构建输出\n' "$ENV_BIN" ;; - release) - printf ' 来源:%s(Releases 提交 %s)\n' "$SRC_LABEL" "$(git -C "$REL_ROOT" rev-parse --short "$REL_COMMIT")" - [ -n "${SRC_DEVCOMMIT:-}" ] && printf ' 对应 Dev 提交:%s\n' "$SRC_DEVCOMMIT" - local art="$REL_ROOT/build/$REL_VERSION/agent-system" - if [ -f "$art" ]; then - printf ' 制品:%s(存在,将做 sha256 校验)\n' "$art" - else - printf ' 制品:%s(缺失!需先在 Releases 仓构建,或换版本)\n' "$art" - fi - printf ' 产物:%s ← Releases 制品\n' "$ENV_BIN" ;; - esac - printf ' 启动:%s serve --addr %s --data %s --plugins %s%s%s\n' \ - "$ENV_BIN" "$ENV_ADDR" "$ENV_DATA" "$ENV_PLUGINS" "${ENV_ARGS:+ $ENV_ARGS}" "${CLI_EXTRA:+ $CLI_EXTRA}" -} - -do_deploy() { - resolve_source - if [ "$CLI_DRYRUN" = 1 ]; then - print_deploy_plan - exit 0 - fi - hist_event deploy begin "$(read_pid)" "from=$SRC_LABEL" - do_stop - if ! materialize_source; then - hist_event deploy fail "$(read_pid)" "物化/构建失败($SRC_LABEL)" - c_err "[$ENV_NAME] 部署失败:来源物化未完成(上方日志)" + 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 - do_start || { hist_event deploy fail "$(read_pid)" "启动失败($SRC_LABEL)"; exit 1; } - hist_event deploy ok "$(read_pid)" "from=$SRC_LABEL" + rm -f "$pwf" + SVC_KIND=postgres + SRC_LABEL="postgres@local" + local pgver merged_tags + pgver="$("$pgbin/postgres" --version 2>/dev/null | awk '{for(i=1;i<=NF;i++) if ($i ~ /^[0-9]+(\.[0-9]+)+$/) {print $i; exit}}' || true)" + merged_tags="$(svc_merge_tags)" + { + printf 'kind=postgres\n' + printf 'env=%s\n' "$ENV_NAME" + printf 'addr=%s\n' "$ENV_ADDR" + printf 'tags=%s\n' "$merged_tags" + printf 'source=%s\n' "$SRC_LABEL" + printf 'version=%s\n' "$pgver" + printf 'user=%s\n' "$user" + printf 'password=%s\n' "$password" + printf 'dbname=%s\n' "$dbname" + printf 'authmode=%s\n' "$authmode" + printf 'pgdata=%s\n' "$pgdata" + } > "$ENV_DIR/svc.env" + chmod 600 "$ENV_DIR/svc.env" + rm -f "$ENV_PID" + hist_event create ok "" "user=$user db=$dbname authmode=$authmode port=${ENV_ADDR##*:}" + c_ok "[$ENV_NAME] 数据库已创建(未启动):scripts/svc.sh $ENV_NAME start" + c_info "[$ENV_NAME] 连接串:scripts/svc.sh db dsn $ENV_NAME --reveal(密码存 svc.env,权限 600)" + return 0 } -# ---- 状态与历史 ---- +# pg_psql SQL [tA]:以登记的管理员+密码走 TCP 执行;tA=取纯值输出。 +pg_psql() { + local sql="$1" ta="${2:-}" pgbin + pgbin="$(pg_bindir)" + PGPASSWORD="$(svc_get password)" "$pgbin/psql" -h "${ENV_ADDR%:*}" -p "${ENV_ADDR##*:}" \ + -U "$(svc_get user)" -d postgres ${ta:+-tA} -c "$sql" 2>/dev/null +} -env_status_line() { - local pid; pid="$(read_pid)" - # 运行态显示实际启动参数(svc.env 由 start 落盘);未运行显示当前配置默认值。 - local addr="$ENV_ADDR" data="$ENV_DATA" src="" a - if [ -f "$ENV_DIR/svc.env" ]; then - a="$(sed -n 's/^addr=//p' "$ENV_DIR/svc.env")"; [ -n "$a" ] && addr="$a" - a="$(sed -n 's/^data=//p' "$ENV_DIR/svc.env")"; [ -n "$a" ] && data="$a" - src="$(sed -n 's/^source=//p' "$ENV_DIR/svc.env")" +pg_do_start() { + local pgbin + pgbin="$(pg_bindir)" || { c_err "[$ENV_NAME] 找不到 postgres 工具链;设 SVC_PG_BIN"; return 1; } + local pgdata; pgdata="$(svc_get pgdata)" + [ -n "$pgdata" ] && [ -d "$pgdata" ] || { c_err "[$ENV_NAME] 无数据目录(先 db create)"; return 1; } + check_port_free || return 1 + local listen="${ENV_ADDR%:*}" + [ "$listen" = "0.0.0.0" ] || [ "$listen" = "::" ] && listen="'*'" + c_info "[$ENV_NAME] 启动 postgres:$pgdata(addr $ENV_ADDR)" + if ! "$pgbin/pg_ctl" -D "$pgdata" -w -t 30 -l "$ENV_DIR/pg.log" \ + -o "-p ${ENV_ADDR##*:} -k $ENV_DIR -c listen_addresses=$listen" start >/dev/null 2>&1; then + c_err "[$ENV_NAME] pg_ctl start 失败,查看日志:tail -40 $ENV_DIR/pg.log" + hist_event start fail "" "pg_ctl start 失败" + return 1 fi - if pid_alive "$pid"; then - printf ' %-10s 运行中 pid=%-7s %s 数据 %s%s\n' "$ENV_NAME" "$pid" "$addr" "$data" "${src:+ 来源 $src}" + local pid; pid="$(head -1 "$pgdata/postmaster.pid" 2>/dev/null || true)" + [ -n "$pid" ] && echo "$pid" > "$ENV_PID" + SVC_PIDCHECK="$pgdata" + SVC_EXEC="pg_ctl -D $pgdata -p ${ENV_ADDR##*:}" + SVC_VERSION="$(svc_get version)" + # 首次启动:建默认库(已存在则跳过)。 + local dbname; dbname="$(svc_get dbname)" + if [ -n "$dbname" ]; then + if ! pg_psql "SELECT 1 FROM pg_database WHERE datname='$dbname'" tA | grep -q 1; then + if pg_psql "CREATE DATABASE \"$dbname\""; then + c_ok "[$ENV_NAME] 已建库:$dbname" + else + c_warn "[$ENV_NAME] 建库 $dbname 未成功(可手动创建)" + fi + fi + fi + record_state "user=$(svc_get user)" "password=$(svc_get password)" "dbname=$(svc_get dbname)" \ + "authmode=$(svc_get authmode)" "pgdata=$pgdata" + if "$pgbin/pg_isready" -h "${ENV_ADDR%:*}" -p "${ENV_ADDR##*:}" >/dev/null 2>&1; then + c_ok "[$ENV_NAME] postgres 就绪 pid=$(read_pid) → $ENV_ADDR(库 $(svc_get dbname),用户 $(svc_get user))" + hist_event start ok "$(read_pid)" "pg_isready ok" + return 0 + fi + c_warn "[$ENV_NAME] pg_isready 未通过(进程已起,查看 $ENV_DIR/pg.log)" + hist_event start warn "$(read_pid)" "进程起但 pg_isready 未过" + return 0 +} + +pg_do_stop() { + local pgbin pgdata pid + pgbin="$(pg_bindir)" || { c_err "[$ENV_NAME] 找不到 postgres 工具链"; return 1; } + pgdata="$(svc_get pgdata)" + pid="$(read_pid)" + if [ -z "$pid" ] || ! pid_alive "$pid"; then + c_info "[$ENV_NAME] postgres 未在运行" + rm -f "$ENV_PID" + return 0 + fi + c_info "[$ENV_NAME] 停止 postgres pid=$pid(pg_ctl -m fast,优雅回写)" + if [ -n "$pgdata" ] && "$pgbin/pg_ctl" -D "$pgdata" -m fast -w -t 30 stop >/dev/null 2>&1; then + rm -f "$ENV_PID" + c_ok "[$ENV_NAME] postgres 已停止" + hist_event stop ok "$pid" "pg_ctl fast" + return 0 + fi + c_warn "[$ENV_NAME] pg_ctl stop 失败,尝试 SIGTERM(绝不 SIGKILL postmaster)" + kill -TERM "$pid" 2>/dev/null || true + for _ in $(seq 1 100); do + pid_alive "$pid" || { rm -f "$ENV_PID"; c_ok "[$ENV_NAME] postgres 已停止(TERM)"; hist_event stop ok "$pid" "SIGTERM 兜底"; return 0; } + sleep 0.1 + done + c_err "[$ENV_NAME] postgres 未能停止(保留现场;查 $pgdata/postmaster.pid 与 $ENV_DIR/pg.log)" + hist_event stop fail "$pid" "未能停止" + return 1 +} + +db_dsn() { + local name="${1:-}" + [ -n "$name" ] || { c_err "用法:scripts/svc.sh db dsn [--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 ' %-10s 未运行 %s 数据 %s%s\n' "$ENV_NAME" "$addr" "$data" "${src:+ 上次 $src}" + printf 'postgresql://%s:***@%s:%s/%s\n' "$user" "$host" "$port" "$dbname" + c_info "密码已登记于 $ENV_DIR/svc.env(--reveal 显示)" fi + return 0 } -global_status() { +# ---- 生命周期分派 ---- + +do_start() { + if pid_alive "$(read_pid)"; then + c_warn "[$ENV_NAME] 已在运行 pid=$(read_pid)(如需换参数/新版本请用 deploy/restart)" + return 0 + fi + case "$SVC_KIND" in + agent) agent_do_start ;; + static) static_do_start ;; + postgres) pg_do_start ;; + *) c_err "[$ENV_NAME] 未知 kind:$SVC_KIND"; return 1 ;; + esac +} + +do_stop() { + local pid; pid="$(read_pid)" + if [ -z "$pid" ]; then + c_info "[$ENV_NAME] 无 pidfile——没有由本脚本启动的实例" + return 0 + fi + if ! pid_alive "$pid"; then + c_warn "[$ENV_NAME] pidfile 指向的进程已不存在(pid=$pid),清理 pidfile" + rm -f "$ENV_PID" + return 0 + fi + case "$SVC_KIND" in + postgres) pg_do_stop ;; + *) + c_info "[$ENV_NAME] 停止旧服务 pid=$pid(SIGTERM,等待优雅停机)" + proc_stop "$pid" + rm -f "$ENV_PID" + c_ok "[$ENV_NAME] 已停止" + hist_event stop ok "$pid" "SIGTERM" + ;; + esac + return 0 +} + +# do_stop_quiet:deploy 前置——未运行时静默返回。 +do_stop_quiet() { + local pid; pid="$(read_pid)" + if [ -z "$pid" ]; then return 0; fi + if ! pid_alive "$pid"; then + rm -f "$ENV_PID" + return 0 + fi + case "$SVC_KIND" in + postgres) pg_do_stop ;; + *) + c_info "[$ENV_NAME] 停止旧服务 pid=$pid(SIGTERM,等待优雅停机)" + proc_stop "$pid" + rm -f "$ENV_PID" + c_ok "[$ENV_NAME] 已停止" + hist_event stop ok "$pid" "SIGTERM" + ;; + esac + return 0 +} + +cmd_rm() { + local name="${1:-}" + [ -n "$name" ] || { c_err "用法:scripts/svc.sh rm [--purge]"; exit 2; } + shift + parse_args "$@" + env_config "$name" + if [ ! -f "$ENV_DIR/svc.env" ]; then + c_err "[$ENV_NAME] 无登记(runtime/$name/svc.env 不存在);rm 只管理本脚本登记的服务" + exit 2 + fi + do_stop + hist_event rm ok "$(read_pid)" "${CLI_PURGE:+purge}" + rm -f "$ENV_DIR/svc.env" "$ENV_DIR/svc.pid" + if [ "$CLI_PURGE" = 1 ]; then + rm -rf "${ENV_DIR:?}" + c_ok "[$ENV_NAME] 已注销并删除整个目录:$ENV_DIR(含数据与历史)" + else + c_ok "[$ENV_NAME] 已注销登记;目录保留(数据/历史在 $ENV_DIR;彻底删除用 --purge)" + fi + return 0 +} + +# ---- 总览与历史 ---- + +svc_pid_state() { # NAME → "alive " | "dead" + local d="$RUNTIME/$1" pid mark + [ -f "$d/svc.pid" ] || { printf dead; return 0; } + pid="$(cat "$d/svc.pid" 2>/dev/null || true)" + [ -n "$pid" ] || { printf dead; return 0; } + mark="$(sed -n 's/^pidcheck=//p' "$d/svc.env" 2>/dev/null | head -1)" + [ -z "$mark" ] && mark="agent-system" + if pid_alive_mark "$pid" "$mark"; then printf 'alive %s' "$pid"; else printf dead; fi + return 0 +} + +global_list() { c_info "服务总览(root: $ROOT)" - local envs=(dev prod) e d + printf ' %-12s %-9s %-7s %-8s %-20s %-24s %s\n' NAME KIND STATE PID ADDR SOURCE TAGS + local names=() d e name kind addr src tags st for d in "$RUNTIME"/*/; do - [ -d "$d" ] || continue + [ -f "${d}svc.env" ] || continue e="$(basename "$d")" [ "$e" = ".worktrees" ] && continue valid_env "$e" || continue - case " ${envs[*]} " in *" $e "*) continue ;; esac - envs+=("$e") + names+=("$e") done - for e in "${envs[@]}"; do - ENV_NAME="$e"; env_config "$e"; env_status_line + for name in ${names[@]+"${names[@]}"}; do + d="$RUNTIME/$name" + kind="$(sed -n 's/^kind=//p' "$d/svc.env" | head -1)"; [ -n "$kind" ] || kind=agent + addr="$(sed -n 's/^addr=//p' "$d/svc.env" | head -1)" + src="$(sed -n 's/^source=//p' "$d/svc.env" | head -1)" + tags="$(sed -n 's/^tags=//p' "$d/svc.env" | head -1)" + if [ -n "${LIST_TAG:-}" ]; then + case ",$tags," in *",${LIST_TAG},"*) ;; *) continue ;; esac + fi + st="$(svc_pid_state "$name")" + if [ "${st%% *}" = alive ]; then + printf ' %-12s %-9s %-7s %-8s %-20s %-24s %s\n' "$name" "$kind" "运行中" "${st#alive }" "$addr" "${src:--}" "${tags:--}" + else + printf ' %-12s %-9s %-7s %-8s %-20s %-24s %s\n' "$name" "$kind" "未运行" "-" "$addr" "${src:--}" "${tags:--}" + fi done + [ "${#names[@]}" -eq 0 ] && c_info "(暂无已登记服务;db create / deploy 会登记)" + return 0 +} + +env_status_detail() { + local pid; pid="$(read_pid)" + local addr="$ENV_ADDR" a src="" ver="" tags="" + local kind="$SVC_KIND" + if [ -f "$ENV_DIR/svc.env" ]; then + a="$(svc_get addr)"; [ -n "$a" ] && addr="$a" + src="$(svc_get source)"; ver="$(svc_get version)"; tags="$(svc_get tags)" + fi + if pid_alive "$pid"; then + printf ' [%s] %s 运行中 pid=%s\n' "$ENV_NAME" "$kind" "$pid" + else + printf ' [%s] %s 未运行\n' "$ENV_NAME" "$kind" + fi + printf ' 地址 %s 数据 %s\n' "$addr" "$ENV_DATA" + [ -n "$src" ] && printf ' 来源 %s 版本 %s\n' "$src" "${ver:--}" + [ -n "$tags" ] && printf ' 标签 %s\n' "$tags" + case "$kind" in + postgres) + printf ' 库 %s 用户 %s\n' "$(svc_get dbname)" "$(svc_get user)" + printf ' DSN postgresql://%s:***@%s:%s/%s(db dsn %s --reveal 看密码)\n' \ + "$(svc_get user)" "${addr%%:*}" "${addr##*:}" "$(svc_get dbname)" "$ENV_NAME" ;; + static) + printf ' 目录 %s\n' "$(svc_get htdocs)" ;; + esac + return 0 } -# jfield KEY LINE:从 history.jsonl 一行里取字符串字段值。 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; } + valid_env "$env" || { c_err "非法服务名:$env"; exit 2; } if [ -f "$RUNTIME/$env/history.jsonl" ]; then files=("$RUNTIME/$env/history.jsonl") else @@ -791,68 +1266,83 @@ cmd_history() { [ -f "$f" ] && files+=("$f") done if [ "${#files[@]}" -eq 0 ]; then - c_info "暂无历史(任何环境都未登记过事件)" + c_info "暂无历史(任何服务都未登记过事件)" exit 0 fi fi - c_info "启动/部署历史($([ -n "$env" ] && echo "环境 $env" || echo "全部环境"),最近 $n 条)" + c_info "事件历史($([ -n "$env" ] && echo "服务 $env" || echo "全部服务"),最近 $n 条)" cat "${files[@]}" | sort | tail -n "$n" | while IFS= read -r l; do [ -n "$l" ] || continue - printf ' %s %-10s %-8s %-6s %-22s %-6s %s\n' \ - "$(jfield ts "$l")" "$(jfield env "$l")" "$(jfield event "$l")" \ + printf ' %s %-10s %-9s %-8s %-6s %-24s %-7s %s\n' \ + "$(jfield ts "$l")" "$(jfield env "$l")" "$(jfield kind "$l")" "$(jfield event "$l")" \ "$(jfield result "$l")" "$(jfield source "$l")" "$(jfield pid "$l")" "$(jfield detail "$l")" done } +# ---- main ---- + main() { [ $# -ge 1 ] || { usage; exit 2; } - # 预扫 --env(支持 --env FILE 与 --env=FILE,可出现在任意位置): - # 配置层优先级 = CLI 参数 > --env 文件 > .svc.env > 系统环境变量 > 默认值。 - local svc_envfile="" a - local pre=() + # 预扫 --config/--env(FILE 与 =FILE 两种写法,可出现在任意位置)。 + local svc_envfile="" pre=() while [ $# -gt 0 ]; do case "$1" in - --env) [ $# -ge 2 ] || { c_err "--env 需要文件路径"; exit 2; }; svc_envfile="$2"; shift 2 ;; - --env=*) svc_envfile="${1#--env=}"; shift ;; + --config|--env) [ $# -ge 2 ] || { c_err "$1 需要文件路径"; exit 2; }; svc_envfile="$2"; shift 2 ;; + --config=*|--env=*) svc_envfile="${1#*=}"; shift ;; *) pre+=("$1"); shift ;; esac done set -- ${pre[@]+"${pre[@]}"} + SVC_ENVFILE="$svc_envfile" [ -f "$ROOT/.svc.env" ] && load_env_file "$ROOT/.svc.env" [ -n "$svc_envfile" ] && load_env_file "$svc_envfile" case "$1" in -h|--help|help) usage; exit 0 ;; esac - if [ "$1" = status ] && [ $# -eq 1 ]; then - global_status - exit 0 - fi case "$1" in + list|status) + shift + LIST_TAG="" + while [ $# -gt 0 ]; do + case "$1" in + --tag) need_value "$@"; LIST_TAG="$2"; shift 2 ;; + *) c_err "list/status 只接受 --tag(未知参数:$1)"; exit 2 ;; + esac + done + global_list + exit 0 + ;; + db) shift; cmd_db "$@"; exit 0 ;; + rm) shift; cmd_rm "$@"; exit 0 ;; commits) shift; cmd_commits "$@"; exit 0 ;; history) shift; cmd_history "$@"; exit 0 ;; esac ENV_NAME="$1"; shift - # 动作词可省略(省略 = deploy);flags 可直接跟随环境名。 + # 动作词可省略(省略 = deploy);flags 可直接跟随服务名。 local action="deploy" case "${1:-}" in deploy|stop|start|restart|status|logs) action="$1"; shift ;; esac - # logs 的行数(兼容 svc.sh dev logs 100)。 if [ "$action" = logs ] && [ "${1:-}" != "" ] && [[ "$1" =~ ^[0-9]+$ ]]; then CLI_LOGS_N="$1"; shift fi parse_args "$@" - if { [ -n "$CLI_FROM" ] || [ -n "$CLI_PICK" ] || [ "$CLI_DRYRUN" = 1 ]; } && [ "$action" != deploy ]; then - c_err "--from/--pick/--dry-run 仅用于 deploy 动作" + if { [ -n "$CLI_FROM" ] || [ -n "$CLI_PICK" ] || [ "$CLI_DRYRUN" = 1 ] || [ -n "$CLI_DIR" ]; } && [ "$action" != deploy ]; then + c_err "--from/--pick/--dry-run/--dir 仅用于 deploy 动作" exit 2 fi env_config "$ENV_NAME" case "$action" in - deploy) do_deploy ;; + deploy) + case "$SVC_KIND" in + agent) agent_do_deploy ;; + static) static_do_deploy && static_do_start ;; + postgres) c_err "[$ENV_NAME] postgres 无 deploy;db create 建库、start 启动"; exit 2 ;; + esac ;; stop) do_stop ;; start) do_start ;; restart) do_stop; do_start ;; - status) env_status_line ;; + status) env_status_detail ;; logs) [ -f "$ENV_LOG" ] && tail -n "${CLI_LOGS_N:-50}" -f "$ENV_LOG" || c_warn "[$ENV_NAME] 暂无日志" ;; esac }