feat(update): 更新进度走 EventBus + SSE(穿越进程重启的通知通道)

## 为什么必须走事件总线(用户口径:为什么不走 eventbus)

升级是**长耗时且会让进程消失**的动作。此前进度只往 log.Printf 写,
WebUI 要看得自己轮询 /kernel/update —— 而轮询在重启瞬间必然断线,
断线那一刻恰恰是用户最需要知道"升到哪一步/失败了/已回滚"的时刻。

EventBus 是内核既有的发布订阅机制(同步投递、handler panic 隔离),
用它不需要新造一条通知通道。

## 三个新事件类型

	update_check     检查结果(只读,不改动)
	update_progress  升级步骤:verify/backup/replace/restart/rollback/done/failed
	update_result    最终结果(含回滚轨迹)

## ★ 顺序约束:先发事件,再动作

升级尾部几步(replace → restart)会让进程消失。顺序一反,最后几步的
事件永远发不出去。所以每个 publish* 都紧贴在其描述的动作**之前**。

## 直接 import events,不搞接口垫层

第一版我写了 EventPublisher 接口 + map[string]interface{} payload 想
"避免依赖",那是自欺欺人:events 包**不依赖任何内部包**(已验证),
import 它不会成环;而垫层会把事件降级成无类型 map,丢掉编译期检查。
已回退该设计,改为直接传 *events.Bus。

## SDK 补 EventBus() getter

PluginSDK 原先只有 Publish/Subscribe,没有暴露总线对象。而长流程
(升级闭环)需要把总线本身传下去持续发事件 —— 只给 Publish 的话,
每个调用点都要手动转发一遍。

## SSE 通路(守卫判据逼出来的正确修法)

TestEveryPublishedEventHasAnOutwardPath 抓到三个新事件"有发布者但无对外
通路"——症状是「发了、没报错、客户端收不到」。修法不是加白名单豁免,
而是真的接进 SSE:三者都进 sseEventRing(不只是实时推送),
断线重连要能补到**最终结论**(update_result),否则用户重连后只看到
"开始升级"而没有结局。

## 调用点

· pluginmgr(kernel_update):p.sdk.EventBus() → DoUpgradeBus
· webui(/kernel/update):h.sdk.EventBus() → runKernelUpgrade(pkg, bus)
· CLI:bus=nil,退化成纯 printf(CLI 没有总线)
This commit is contained in:
JianFeeeee
2026-10-26 09:23:58 +08:00
parent d17e261b76
commit 5b759e80d7
8 changed files with 190 additions and 7 deletions

View File

@ -1,4 +1,5 @@
// Package kernelupgrade 实现**内核**的升级闭环:探测 / 校验 / 备份 / 原子替换 / 重启 / 回滚。
// 内核升级闭环:探测 / 校验 / 备份 / 原子替换 / 重启 / 回滚。
// 包级说明见 doc.go(update 子系统)。
//
// 抽成独立包而不是留在 cmd/homed-upgrade:内核侧的 kernel_update 工具
// 与 CLI 必须走**同一套**逻辑。两份实现必然漂移,而这条路径上漂移的
@ -22,6 +23,8 @@ import (
"sort"
"strings"
"time"
"github.com/JianFeeeee/HologramAgent/internal/events"
)
// binVersion 是 homed --version 的输出。
@ -248,6 +251,17 @@ func FileSize(p string) int64 {
// 每一步失败都往回退,且**回滚后必须能正常启动**——这是本函数的核心承诺:
// 宁可升级失败,不可让机器起不来。
func DoUpgrade(root, binPath, newDG, pluginsDir, dataDir string) error {
return DoUpgradeBus(root, binPath, newDG, pluginsDir, dataDir, nil)
}
// DoUpgradeBus 同 DoUpgrade,但把每个步骤发布到事件总线。
//
// ★ 为什么必须"先发事件再动作":升级的最后几步会让进程消失
// (replace → restart)。顺序一反,最后几步的事件永远发不出去 ——
// 而那时用户最需要知道"升级到哪一步了/失败了/已回滚"。
//
// bus 为 nil 时退化成纯 printf(CLI 场景没有总线)。
func DoUpgradeBus(root, binPath, newDG, pluginsDir, dataDir string, bus *events.Bus) error {
newBin := FindFile(root, "homed")
if newBin == "" {
return fmt.Errorf("包内无 homed")
@ -258,6 +272,7 @@ func DoUpgrade(root, binPath, newDG, pluginsDir, dataDir string) error {
if err != nil {
return fmt.Errorf("预检失败,新二进制不可执行: %w", err)
}
publishProgress(bus, "kernel", "", "verify", "新内核可执行,版本 "+nv.Version)
fmt.Printf("\n== 预检通过 ==\n新内核 %s 可执行\n", nv.Version)
// ── 备份:只备本次会动的文件(不整目录拷贝,那是几百 MB)──
@ -275,6 +290,7 @@ func DoUpgrade(root, binPath, newDG, pluginsDir, dataDir string) error {
return fmt.Errorf("备份 %s: %w", t, err)
}
}
publishProgress(bus, "kernel", "", "backup", fmt.Sprintf("已备份 %d 个文件", len(targets)))
fmt.Printf("已备份 %d 个文件 → %s\n", len(targets), backup)
// ── 写待确认升级标记(在替换**之前**)──
@ -304,6 +320,7 @@ func DoUpgrade(root, binPath, newDG, pluginsDir, dataDir string) error {
}
rollback := func(cause error) error {
publishProgress(bus, "kernel", "", "rollback", cause.Error())
fmt.Printf("\n❌ %v\n== 开始回滚 ==\n", cause)
if dataDir != "" {
if cerr := ClearPendingUpgrade(dataDir); cerr != nil {
@ -354,6 +371,7 @@ func DoUpgrade(root, binPath, newDG, pluginsDir, dataDir string) error {
if err != nil || got.Version != nv.Version || got.Commit != nv.Commit {
return rollback(fmt.Errorf("版本复核不符:期望 %s/%s,实际 %v", nv.Version, ShortCommit(nv.Commit), got))
}
publishResult(bus, "kernel", "", true, curVer.Version, got.Version, false, "")
fmt.Printf("\n✅ 升级完成:%s (commit %s)\n", got.Version, ShortCommit(got.Commit))
fmt.Printf(" 备份保留在 %s(确认稳定后可删)\n", backup)
return nil