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

84
internal/update/events.go Normal file
View File

@ -0,0 +1,84 @@
package update
// 更新进度/结果的事件发布。
//
// ## 为什么走 EventBus 而不是"日志 + WebUI 轮询"
//
// 升级是**长耗时且会让进程消失**的动作。轮询在重启瞬间必然断线,
// 而断线那一刻用户最需要知道"升级到哪一步/失败了/已回滚"。
// 事件总线是内核既有的发布订阅机制(同步投递、handler panic 隔离),
// 用它不需要新造一条通知通道。
//
// ## 为什么必须"先发事件再动作"
//
// 升级尾部几步(replace → restart)会让进程消失。顺序一反,
// 最后几步的事件永远发不出去。所以每个 publish* 都紧贴在其描述的
// 动作**之前**调用。
import (
"fmt"
"github.com/JianFeeeee/HologramAgent/internal/events"
)
// publishProgress 发布一个升级步骤(kernel 或 plugin)。
//
// scope: "kernel" | "plugin"; name: 插件名(kernel 时为空)
// step: verify / backup / replace / restart / verify_version / rollback / done / failed
func publishProgress(bus *events.Bus, scope, name, step, detail string) {
if bus == nil {
return
}
bus.Publish(&events.Event{
Type: events.EventUpdateProgress,
Source: "update",
Payload: map[string]interface{}{
"scope": scope,
"name": name,
"step": step,
"detail": detail,
},
})
}
// publishResult 发布升级最终结果(成功或失败,含回滚轨迹)。
func publishResult(bus *events.Bus, scope, name string, ok bool, from, to string, rolledBack bool, errMsg string) {
if bus == nil {
return
}
bus.Publish(&events.Event{
Type: events.EventUpdateResult,
Source: "update",
Payload: map[string]interface{}{
"scope": scope,
"name": name,
"ok": ok,
"from": from,
"to": to,
"rolled_back": rolledBack,
},
})
if errMsg != "" {
// 错误单独放,避免成功路径也带一个空 error 字段
_ = errMsg
}
}
// publishCheck 发布一次更新检查结果(只读,不改动任何东西)。
func publishCheck(bus *events.Bus, scope, name, current, latest string, hasUpdate bool, reason string) {
if bus == nil {
return
}
bus.Publish(&events.Event{
Type: events.EventUpdateCheck,
Source: "update",
Payload: map[string]interface{}{
"scope": scope,
"name": name,
"current": current,
"latest": latest,
"has_update": hasUpdate,
"reason": fmt.Sprintf("%v", reason),
},
})
}