refactor(update): 内核与插件更新合并为 update 子系统(三端共享一个底层)

## 为什么合并(用户口径:更新部分应独立为子系统)

之前 kernelupgrade / pluginupdate 两个包,但它们共享三样东西,
分开写就要各复制一份,然后慢慢不一致:

① **版本比较** —— 合并前共 6 个函数做同一件事:
   kernel 侧 ParseSemver / CmpSemver / versionNewer / trimTagV
   plugin 侧 trimV / isNewer / semverNums(还自带一份正则)
   且**行为不一致**:kernel 侧只挡 len<2,"1.4" 漏过去被填成 1.4.0;
   plugin 侧的正则要求两段所以也漏。两份都把 "1.4" 当合法版本。

② **两阶段交互**(检查 → 确认 → 执行)—— 同一套用户口径。

③ **失败要响亮** —— 都不静默降级。

## 收敛结果

internal/update/
  doc.go     子系统头:职责边界、三端共享契约、为何不分家
  semver.go  ParseSemver / CmpSemver / Newer / TrimTag(**唯一一份**)
  kernel.go  内核升级闭环(解包→校验→备份→替换→重启→复核→回滚)
  release.go GitHub Release 查询
  plugin.go  插件升级(查仓库→拉源码→hmapdev 编译→产物)

三端共享:
  agent 工具(plugin_update / kernel_update)  ┐
  WebAPI(/plugins/update、/kernel/update)    ├─ internal/update
  CLI(homed-upgrade)                         ┘

入口层只做参数解析与结果呈现,不含业务逻辑 —— 否则会出现
"agent 说没有新版、WebUI 却升了级"的分裂状态。

## 合并时抓到的两个真实 bug

① **ParseSemver 只挡 len<2** ⇒ "1.4" 被零值填成 1.4.0,
   「1.4 → 1.4.0」被判完全相同(说"无需升级"),而两者内容未必相同。
   现在要求恰好三段。

② **不认 v 前缀** ⇒ GitHub release tag 全是 v1.4.0 形态,
   CLI 对"当前 v1.4.0 vs 包内 v1.4.0"判不可比、只能退回字符串比较。
   现在 TrimTag 去 v 前缀后再比。

## 新增入口

· plugin_update(agent 工具)+ GET/POST /plugins/update
  —— 两阶段:apply=false 只检查(列出 updatable / no_repo 两类,
     后者是"未声明仓库、无法自动更新"的内置/私有插件);
     apply=true 需显式 plugins=all 或名单(不默认全升)
· kernel_update(agent 工具)+ GET/POST /kernel/update
  —— 两阶段同构;apply 只收本机包路径(URL 下载是 CLI 职责,
     agent 不该在推理中发起长下载)

判据:
· internal/update: Newer / TrimTag / ParseSemver 的边界(含上述两个 bug 的回归)
· pluginmgr 入口层 5 条:检查形状、apply 必须显式给范围、
  kernel apply 必须给 pkg 且指向 homed-upgrade、拒绝目录当包
This commit is contained in:
JianFeeeee
2026-10-26 01:05:09 +08:00
parent 02022bc13d
commit 475aac867b
13 changed files with 1542 additions and 71 deletions

View File

@ -0,0 +1,126 @@
// kernel_update 工具的 agent 侧实现。
//
// ## 三个入口共享一个底层(架构约束)
//
// agent 工具 kernel_update ┐
// WebAPI /kernel/update ├─ internal/update(CheckRelease / DoUpgrade)
// CLI homed-upgrade ┘
//
// 三份实现必然漂移("agent 说没有新版、WebUI 却升了"),所以入口层只做
// 参数解析与结果呈现。
//
// ## 为什么实现放在 pluginmgr 而不是 agent/core
//
// 1. 与 plugin_update 对称(都在 pluginmgr,用户认知一致)
// 2. agent/core 拿不到 SDK 句柄,而升级完成后要 RequestKernelRestart
// (那是 sdk.KernelControlAPI)
// 3. 与内核工具分派(toolcall.go)不同:这里走插件的 RegisterTool,
// 不需要改核心包
//
// ## 两阶段(用户口径)
//
// apply=false(默认)→ 只查 GitHub Release 与本地版本比较,不改动任何东西
// apply=true → 下载/解包 → 校验 → 备份 → 原子替换 → 重启 → 版本复核
// → 失败自动回滚(DoUpgrade 内部完成)
package pluginmgr
import (
"fmt"
"os"
"path/filepath"
"github.com/JianFeeeee/HologramAgent/internal/update"
)
// kernelUpdateCheck 是 kernel_update 的检查半边(apply=false)。
func (p *Plugin) kernelUpdateCheck() (interface{}, error) {
cur := currentKernelVersion(p)
info := update.CheckRelease(cur)
return map[string]interface{}{
"apply": false,
"current": info.Current,
"latest": info.Latest,
"has_update": info.HasUpdate,
"asset": info.Asset,
"release": info.LatestURL,
"reason": info.Reason,
"hint": "确认后传 apply=true 执行升级(会替换二进制并重启内核,连接断开数秒)",
}, nil
}
// kernelUpdateApply 是 kernel_update 的执行半边(apply=true)。
//
// 只接受**本机已有的包**(pkg 参数):从 URL 现场下载需要处理
// 校验/断点/重试,那是 CLI 的职责(homed-upgrade),agent 不应在
// 推理过程中发起长下载(会占满工具调用超时)。
func (p *Plugin) kernelUpdateApply(pkgPath string) (interface{}, error) {
if pkgPath == "" {
return map[string]interface{}{
"error": "apply=true 需要 pkg(本机已存在的安装包路径;下载请跑 homed-upgrade -pkg <url>)",
}, nil
}
st, err := os.Stat(pkgPath)
if err != nil || st.IsDir() {
return map[string]interface{}{
"error": fmt.Sprintf("pkg 无效:%s", pkgPath),
}, nil
}
tmp, err := os.MkdirTemp("", "kernel-upgrade-*")
if err != nil {
return nil, fmt.Errorf("建临时目录: %w", err)
}
defer os.RemoveAll(tmp)
root, err := update.ExtractPkg(pkgPath, tmp)
if err != nil {
return nil, fmt.Errorf("解包: %w", err)
}
newBin := update.FindFile(root, "homed")
newDG := update.FindFile(root, "depgraph.bin")
if newBin == "" {
return nil, fmt.Errorf("包内找不到 homed")
}
// ★ 升级会让本进程消失:先把"已受理"回给模型,再执行替换。
//
// 否则模型看到的是工具调用超时/断线,无法区分"升级成功"和"工具坏了"。
// 返回后由本 goroutine 继续跑 DoUpgrade(替换+重启+回滚)。
if kc := p.sdk.KernelControl(); kc != nil {
_ = kc.RequestKernelRestart("kernel_update apply")
}
go func() {
if err := update.DoUpgrade(root, kernelBinPath(p), newDG, p.pluginDir); err != nil {
// DoUpgrade 内部失败已自行回滚;这里只留一条诊断日志
fmt.Fprintf(os.Stderr, "[pluginmgr] kernel_update upgrade failed (rolled back): %v\n", err)
}
}()
return map[string]interface{}{
"accepted": true,
"message": "升级已受理:正在替换二进制并重启。完成后用 kernel_update(apply=false)确认新版本。",
}, nil
}
// currentKernelVersion 读本机 homed 自报版本(尽力而为)。
func currentKernelVersion(p *Plugin) *update.BinVersion {
v, err := update.ReadBinVersion(kernelBinPath(p))
if err != nil {
return nil
}
return v
}
// kernelBinPath 是本机 homed 的路径。
//
// 由 pluginDir 推导(<data>/../homed 不总是成立),优先按**本进程**
// 的可执行路径:homed 与内核同二进制,这是最可靠的来源。
func kernelBinPath(p *Plugin) string {
if exe, err := os.Executable(); err == nil && filepath.Base(exe) == "homed" {
return exe
}
if _, err := os.Stat("/usr/local/bin/homed"); err == nil {
return "/usr/local/bin/homed"
}
return "homed"
}

View File

@ -0,0 +1,105 @@
package pluginmgr
import (
"strings"
"testing"
)
// ── plugin_update / kernel_update 的入口层判据 ────────────────
//
// 这里只测**入口层**(参数解析、范围选择、结果形状);
// 底层逻辑(Check / Upgrade / DoUpgrade)的判据在各自包里:
// internal/update — 拉源码、编译、版本比较
// internal/update — 版本比较、解包、替换、回滚
// 入口层若长出业务逻辑,就违背了"三端共享一个底层"的架构约束。
// 判据 1:plugin_update 检查阶段(apply=false)不要求 plugins 参数,
// 且结果含 updatable / no_repo 两类 —— 两者必须分开。
//
// 混在一起的后果:用户无法区分"没更新"和"根本不能自动更新"(内置插件)。
func TestPluginUpdate_CheckShape(t *testing.T) {
p := &Plugin{pluginDir: t.TempDir()} // 空目录:没有已装插件
out, err := p.pluginUpdate(false, "")
if err != nil {
t.Fatalf("检查不应失败: %v", err)
}
m, ok := out.(map[string]interface{})
if !ok {
t.Fatalf("返回应为 map,实际 %T", out)
}
if m["apply"] != false {
t.Errorf("apply 应为 false,实际 %v", m["apply"])
}
if _, ok := m["updatable"]; !ok {
t.Error("结果应有 updatable 字段")
}
if _, ok := m["no_repo"]; !ok {
t.Error("结果应有 no_repo 字段(未声明仓库的插件单独列出)")
}
}
// 判据 2:apply=true 但没给 plugins ⇒ 明确报错,不默认全升。
//
// 默认全升的危险:一次误触就把所有插件换了。
func TestPluginUpdate_ApplyRequiresScope(t *testing.T) {
p := &Plugin{pluginDir: t.TempDir()}
out, err := p.pluginUpdate(true, "")
if err != nil {
t.Fatalf("应返回可呈现的错误而不是 error: %v", err)
}
m := out.(map[string]interface{})
if _, hasErr := m["error"]; !hasErr {
t.Errorf("apply=true 缺 plugins 时必须报错,实际: %v", m)
}
}
// 判据 3:kernel_update 检查阶段不依赖包存在,返回版本比较结果。
func TestKernelUpdate_CheckShape(t *testing.T) {
p := &Plugin{pluginDir: t.TempDir()}
out, err := p.kernelUpdateCheck()
if err != nil {
t.Fatalf("检查不应失败: %v", err)
}
m := out.(map[string]interface{})
if m["apply"] != false {
t.Errorf("apply 应为 false,实际 %v", m["apply"])
}
// current/latest/reason 三者在"读不到本机版本"时也要给
if m["reason"] == nil {
t.Error("结果应有 reason 字段(说明能否比较/为何不能)")
}
}
// 判据 4:kernel_update apply 缺 pkg ⇒ 明确报错并指向 CLI。
func TestKernelUpdate_ApplyRequiresPkg(t *testing.T) {
p := &Plugin{pluginDir: t.TempDir()}
out, err := p.kernelUpdateApply("")
if err != nil {
t.Fatalf("应返回可呈现的错误: %v", err)
}
m := out.(map[string]interface{})
if !strings.Contains(fmtSprint(m["error"]), "pkg") {
t.Errorf("错误应说明需要 pkg,实际: %v", m["error"])
}
// 并应提示下载走 CLI(agent 不该在推理中发起长下载)
if !strings.Contains(fmtSprint(m["error"]), "homed-upgrade") {
t.Errorf("错误应指向 homed-upgrade,实际: %v", m["error"])
}
}
// 判据 5:kernel_update apply 的 pkg 是目录/不存在 ⇒ 拒绝。
func TestKernelUpdate_ApplyRejectsBadPkg(t *testing.T) {
p := &Plugin{pluginDir: t.TempDir()}
out, _ := p.kernelUpdateApply(t.TempDir()) // 目录不是包
m := out.(map[string]interface{})
if _, hasErr := m["error"]; !hasErr {
t.Errorf("目录不应被当作安装包,实际: %v", m)
}
}
func fmtSprint(v interface{}) string {
if s, ok := v.(string); ok {
return s
}
return ""
}

View File

@ -12,6 +12,7 @@ import (
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
@ -21,6 +22,7 @@ import (
"time"
"github.com/JianFeeeee/HologramAgent/internal/plugin"
"github.com/JianFeeeee/HologramAgent/internal/update"
sdk "github.com/JianFeeeee/HologramAgent/internal/sdk"
)
@ -492,13 +494,253 @@ func (p *Plugin) registerTools(s *sdk.PluginSDK) {
}
return p.pluginInfo(name)
})
s.RegisterTool("kernel_update", sdk.ToolDef{
Name: "kernel_update",
Description: "检查/升级**内核**(homed 二进制)。两阶段:默认(apply=false)只查 GitHub Release " +
"与本地版本比较,不改动任何东西;看过结果后传 apply=true + pkg(本机已存在的安装包路径)" +
"真正升级:校验 → 备份 → 原子替换 → 重启 → 版本复核 → 失败自动回滚。" +
"升级会让内核重启,连接断开数秒。从 URL 下载新包请用 CLI 的 homed-upgrade。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"apply": map[string]interface{}{
"type": "boolean",
"description": "true=执行升级;false/缺省=只检查。默认 false",
},
"pkg": map[string]interface{}{
"type": "string",
"description": "安装包在本机的路径(.tar.gz)。仅 apply=true 时需要",
},
},
},
// 检查只读;apply 会替换二进制并重启内核
Serial: true,
}, func(args map[string]interface{}) (interface{}, error) {
apply, _ := args["apply"].(bool)
pkgPath, _ := args["pkg"].(string)
if apply {
return p.kernelUpdateApply(pkgPath)
}
return p.kernelUpdateCheck()
})
s.RegisterTool("plugin_update", sdk.ToolDef{
Name: "plugin_update",
Description: "检查/升级插件。两阶段设计:默认(apply=false)只**检查**哪些插件有新版,不改动任何东西;" +
"看过清单后传 apply=true + plugins(all 或逗号分隔的名单) 真正升级:从插件仓库拉最新源码 → hmapdev 现场编译 → 安装新版(保留配置)。" +
"未声明仓库的插件会明确说明无法自动更新。内置插件不在此列(随内核升级)。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"apply": map[string]interface{}{
"type": "boolean",
"description": "true=真正升级;false/缺省=只检查不改动。默认 false",
},
"plugins": map[string]interface{}{
"type": "string",
"description": "升级范围:all=全部有更新的插件;或逗号分隔的名单如 qq,memo。仅 apply=true 时需要",
},
},
},
// 检查是只读;apply 会改磁盘与运行态 —— 按串行处理,避免与 install/remove 并发撞车
Serial: true,
}, func(args map[string]interface{}) (interface{}, error) {
apply, _ := args["apply"].(bool)
scope, _ := args["plugins"].(string)
return p.pluginUpdate(apply, scope)
})
}
// pluginUpdate 是 plugin_update 工具的实现:两阶段(检查 / 升级)。
//
// ## 架构约束:三个入口共享一个底层
//
// 本方法与 WebAPI(/plugins/update)都调 internal/update 的
// Check/Upgrade —— 两份实现必然漂移("agent 说没有更新、WebUI 却升了级")。
// 这里只做**参数解析 + 范围选择 + 调底层 + 呈现**。
//
// ## 两阶段(用户口径)
//
// 阶段 1(apply=false):只检查,不改动任何东西
// 阶段 2(apply=true): 对 plugins 指定的范围真正升级
// plugins=all → 升级阶段 1 发现的全部有更新项
// plugins=qq,memo → 只升指定项(不管有没有更新都试)
//
// ## 为什么编译副本放在 pluginDir/_src
//
// 拉下来的源码副本留在磁盘上(而不是用完就删):升级失败时它是现场,
// 用户能直接进目录看 hmapdev 的报错。目录以 _ 开头,不会被内核当成插件
// 加载(入口判据是 plugin.json,_src 里没有)。
func (p *Plugin) pluginUpdate(apply bool, scope string) (interface{}, error) {
dir := p.pluginDir
if dir == "" {
return nil, fmt.Errorf("pluginDir 不可用(内核未提供插件目录)")
}
// ── 阶段 1:检查全部已装插件 ──
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("读插件目录 %s: %w", dir, err)
}
type item struct {
manifest *plugin.PluginManifest
repo string
info *update.UpdateInfo
}
var items []item
var builtIn []string
for _, e := range entries {
if !e.IsDir() || strings.HasPrefix(e.Name(), "_") || strings.HasPrefix(e.Name(), ".") {
continue
}
pdir := filepath.Join(dir, e.Name())
m, err := plugin.ReadManifest(pdir)
if err != nil {
continue // 不是插件目录
}
repo := update.RepoFromManifest(pdir)
if repo == "" {
builtIn = append(builtIn, m.Name)
continue
}
items = append(items, item{manifest: m, repo: repo,
info: update.Check(pdir, repo)})
}
// ── 汇总阶段 1 结果 ──
type one struct {
Name string `json:"name"`
Current string `json:"current"`
Latest string `json:"latest,omitempty"`
Repo string `json:"repo,omitempty"`
HasUpdate bool `json:"has_update"`
Reason string `json:"reason,omitempty"`
}
checks := make([]one, 0, len(items))
var updatable []string
for _, it := range items {
checks = append(checks, one{
Name: it.manifest.Name, Current: it.manifest.Version,
Latest: it.info.Latest, Repo: it.repo,
HasUpdate: it.info.HasUpdate, Reason: it.info.Reason,
})
if it.info.HasUpdate {
updatable = append(updatable, it.manifest.Name)
}
}
sort.Strings(builtIn)
if !apply {
return map[string]interface{}{
"apply": false,
"checked": len(checks),
"updatable": updatable,
"no_repo": builtIn, // 未声明仓库:无法自动更新(内置/私有)
"plugins": checks,
"hint": "确认清单后传 apply=true, plugins=all(或指定名单)执行升级",
}, nil
}
// ── 阶段 2:升级 ──
scope = strings.TrimSpace(scope)
if scope == "" {
return map[string]interface{}{"error": "apply=true 需要 plugins(all 或逗号分隔名单)"}, nil
}
var targets []string
if strings.EqualFold(scope, "all") {
targets = updatable
if len(targets) == 0 {
return map[string]interface{}{
"apply": true, "upgraded": []string{},
"message": "没有可升级的插件(全部已是最新或未声明仓库)",
}, nil
}
} else {
for _, n := range strings.Split(scope, ",") {
if n = strings.TrimSpace(n); n != "" {
targets = append(targets, n)
}
}
}
// 源码副本目录(见函数头注释:留下当升级失败的现场)
workDir := filepath.Join(dir, "_src")
os.MkdirAll(workDir, 0755)
hmapdev := "hmapdev"
if _, err := exec.LookPath(hmapdev); err != nil {
return nil, fmt.Errorf("hmapdev 不可用:现场编译插件需要它(PATH 或 sdk store 里找一下)")
}
type result struct {
Name string `json:"name"`
From string `json:"from"`
To string `json:"to"`
Status string `json:"status"` // upgraded / up_to_date / failed
Error string `json:"error,omitempty"`
}
results := make([]result, 0, len(targets))
nOK, nFail := 0, 0
for _, name := range targets {
pdir := filepath.Join(dir, name)
if _, err := plugin.ReadManifest(pdir); err != nil {
results = append(results, result{Name: name, Status: "failed",
Error: "已安装插件里找不到 " + name})
nFail++
continue
}
repo := update.RepoFromManifest(pdir)
if repo == "" {
results = append(results, result{Name: name, Status: "failed",
Error: "未声明仓库,无法自动升级"})
nFail++
continue
}
from := update.VersionFromManifest(pdir)
up, err := update.Upgrade(repo, workDir, hmapdev)
if err != nil {
if strings.Contains(err.Error(), "已是最新") {
results = append(results, result{Name: name, From: from, To: from,
Status: "up_to_date"})
continue
}
results = append(results, result{Name: name, From: from, Status: "failed",
Error: err.Error()})
nFail++
continue
}
// 产物交给既有安装通道(保留配置、原地覆盖)——不在这里重写安装逻辑
if _, ierr := p.installFromPath(up.HmapPath, true); ierr != nil {
results = append(results, result{Name: name, From: from, To: up.To,
Status: "failed", Error: "编译成功但安装失败: " + ierr.Error()})
nFail++
continue
}
results = append(results, result{Name: name, From: from, To: up.To,
Status: "upgraded"})
nOK++
}
return map[string]interface{}{
"apply": true,
"scope": scope,
"ok": nOK,
"failed": nFail,
"results": results,
"hint": "升级成功后插件会以新版本重启;若插件未自动重启,调 plugin_restart",
}, nil
}
// ======== HTTP API ========
func (p *Plugin) startHTTPServer() {
p.mux.HandleFunc("/plugins", p.handlePlugins)
p.mux.HandleFunc("/plugins/", p.handlePluginByID)
// 插件更新:与 agent 的 plugin_update 工具**同一个底层**
// (都走 p.pluginUpdate → internal/update),只差参数来源。
p.mux.HandleFunc("/plugins/update", p.handlePluginsUpdate)
listen, err := net.Listen("tcp", p.httpAddr)
if err != nil {
@ -530,6 +772,42 @@ func (p *Plugin) HTTPURL() string {
return p.httpURL
}
// handlePluginsUpdate 是 /plugins/update 的 WebAPI 入口。
//
// GET → 检查更新(apply=false 的语义,只读)
// POST {plugins} → 升级(apply=true;plugins=all 或逗号名单)
//
// 与 agent 工具共用 p.pluginUpdate —— 本 handler 不含任何业务逻辑,
// 只做 HTTP 形状的转换。若在这里重写更新逻辑,就会出现
// "WebUI 查到有更新、agent 查不到"的分裂状态。
func (p *Plugin) handlePluginsUpdate(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
out, err := p.pluginUpdate(false, "")
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, out)
case http.MethodPost:
var body struct {
Plugins string `json:"plugins"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
out, err := p.pluginUpdate(true, body.Plugins)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]interface{}{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, out)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (p *Plugin) handlePlugins(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
@ -1097,6 +1375,15 @@ type pluginPackage struct {
Version string `json:"version"`
Entry string `json:"entry"`
Platforms []string `json:"platforms,omitempty"`
// Repository 是插件的源码仓库地址。
//
// ★ 复用既有字段名(PluginManifest.Repository 早已存在、plugin_install
// 也已在输出它),不另立 "repo" —— 同一件事记两处必然漂移。
// plugin_update 靠它「拉最新源码 → 现场编译 → 安装新版」。
//
// 为空表示未声明仓库(私有插件 / 内置插件 / 手工装的产物),
// 此时 plugin_update 必须**明确说"未声明仓库"**而不是假装可用。
Repository string `json:"repository,omitempty"`
}
const (