mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 14:57:38 +00:00
批 1,不含 SDK 仓与目录改名。 - go.mod 第 1 行 module 路径 - 366 个 .go 文件的完整前缀 github.com/JianFeeeee/HomeAgent 机械替换 (857 处,仅完整前缀;展示名/注释里的 HomeAgent 字样留给批 5) - 未改:third_party 目录名、SDK 自身 go.mod module、go.mod replace 目标、 homed/waiter 可执行名、.hmap 后缀
285 lines
9.3 KiB
Go
285 lines
9.3 KiB
Go
// Package migration 提供**数据目录迁移**的内核能力(伪关机 → 迁移 → 唤醒)。
|
||
//
|
||
// ## 它提供什么
|
||
//
|
||
// 两个工具(暴露给模型/WebUI):
|
||
//
|
||
// migrate_data_preflight → 只读体检:报告能不能迁、要迁多少、有哪些风险
|
||
// migrate_data_plan → 生成可执行迁移脚本(伪关机窗口由脚本自己 stop 覆盖)
|
||
//
|
||
// ## ★ 为什么不提供"一键迁移"工具
|
||
//
|
||
// 迁移是**破坏性且不可逆**的(21G 数据 + 12 处 DB 改写 + 46 个文件改写),
|
||
// 而且内核跑在 systemd 沙箱里(ProtectSystem/ProtectHome)**本来就写不了
|
||
// /etc/systemd**,更没法把 /home/newqqagent 搬到别的挂载点(跨目录 rename 会失败,
|
||
// 必须 rsync 全量)。
|
||
//
|
||
// ⇒ 内核的职责是:**体检 + 生成正确的脚本 + 提供伪关机/唤醒能力**;
|
||
//
|
||
// 真正的搬数据由运维执行脚本。这样每一步都可审查、可回滚。
|
||
//
|
||
// ## 依赖声明
|
||
//
|
||
// Requires 里**故意不含** plugin-host:本模块只读 fs + 读 config,
|
||
// 伪关机能力(PluginQuiescer)由调用方在需要时注入,不硬依赖。
|
||
package migration
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"syscall"
|
||
|
||
"github.com/JianFeeeee/HologramAgent/internal/kernelboot"
|
||
"github.com/JianFeeeee/HologramAgent/internal/kernelmod"
|
||
"github.com/JianFeeeee/HologramAgent/internal/systemd"
|
||
)
|
||
|
||
func init() { kernelmod.Register(Module{}) }
|
||
|
||
// Module 是数据目录迁移模块。
|
||
type Module struct{}
|
||
|
||
func (Module) Name() string { return "migration" }
|
||
|
||
// Requires 只需要配置(读 data_dir)与 kernel-io(日志)。
|
||
func (Module) Requires() []string { return []string{"config", "kernel-io"} }
|
||
|
||
func (Module) Provides() []string { return []string{"migration"} }
|
||
|
||
// MigrateReport 是体检结果。
|
||
type MigrateReport struct {
|
||
DataDir string `json:"data_dir"`
|
||
TotalBytes int64 `json:"total_bytes"`
|
||
Dirs []DirInfo `json:"dirs"`
|
||
Mounts []string `json:"mounts"` // 必须先卸载的挂载点
|
||
Risks []string `json:"risks"` // 会导致数据丢失/错误的点
|
||
DBPathKeys []string `json:"db_path_keys"` // 数据库里存绝对路径的键
|
||
SameDevice bool `json:"same_device"` // 目标与源是否同一设备(决定能否 rename)
|
||
CanRename bool `json:"can_rename"` // 同设备可直接 rename,否则必须 rsync
|
||
ScriptPath string `json:"script_path,omitempty"`
|
||
}
|
||
|
||
// DirInfo 是单目录的信息。
|
||
type DirInfo struct {
|
||
Name string `json:"name"`
|
||
Bytes int64 `json:"bytes"`
|
||
Kind string `json:"kind"` // data | runtime | optional | mount
|
||
}
|
||
|
||
func (Module) Init(ks *kernelboot.KernelState) error {
|
||
// 体检结果放在 KernelState 上,供工具/WebUI 读(可选能力,nil 安全)
|
||
ks.MigrationPreflight = func(target string) (interface{}, error) {
|
||
return Preflight(ks.DataDir, target)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Preflight 体检:数据目录现在是什么状态、迁到 target 需要注意什么。
|
||
func Preflight(dataDir, target string) (*MigrateReport, error) {
|
||
rep := &MigrateReport{DataDir: dataDir}
|
||
|
||
// 目录分类(来自 2026-10-17 生产盘点,21G)
|
||
// data=必须迁 runtime=不迁(重启重建) optional=可丢 mount=挂载点需先卸
|
||
classify := map[string]string{
|
||
"agentfs": "mount",
|
||
"plugindev": "data",
|
||
"blocklauncher": "data",
|
||
"browser_profiles": "data",
|
||
"models": "data",
|
||
"plugins": "data",
|
||
"mcbridge": "data",
|
||
"memory": "data",
|
||
"plugin_data": "data",
|
||
"knowledge": "data",
|
||
"skills": "data",
|
||
"mcp": "data",
|
||
"nlp": "data",
|
||
"adapters": "data",
|
||
"data": "data",
|
||
"uploads": "data",
|
||
"device_media": "data",
|
||
"webui_files": "data",
|
||
"changesets": "data",
|
||
"heartbeat": "runtime",
|
||
"log": "optional",
|
||
"backups": "optional",
|
||
"memory.bak": "optional",
|
||
}
|
||
|
||
entries, err := os.ReadDir(dataDir)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("读数据目录: %w", err)
|
||
}
|
||
for _, e := range entries {
|
||
name := e.Name()
|
||
if !e.IsDir() {
|
||
continue
|
||
}
|
||
info := DirInfo{Name: name, Kind: classify[name]}
|
||
if info.Kind == "" {
|
||
info.Kind = "data" // 未知目录默认**必须迁**(宁可多迁,不可漏迁)
|
||
}
|
||
info.Bytes = dirSize(filepath.Join(dataDir, name))
|
||
rep.Dirs = append(rep.Dirs, info)
|
||
rep.TotalBytes += info.Bytes
|
||
}
|
||
sort.Slice(rep.Dirs, func(i, j int) bool { return rep.Dirs[i].Bytes > rep.Dirs[j].Bytes })
|
||
|
||
// ① 挂载点:agentfs 是 overlay(lower/upper/work),不卸就搬空壳。
|
||
//
|
||
// ★ 风险说明**无条件**报出,只有"要不要 umount"取决于实际挂载状态。
|
||
//
|
||
// 为什么分开:如果只在挂载时才提风险,那"现在没挂、迁移脚本跑起来时
|
||
// 服务又把它挂上了"这种时序就完全没提示 —— 而 agentfs 恰恰是内核
|
||
// 启动时才挂的。运维在**停机状态**做体检会看不到这条警示,
|
||
// 但迁移脚本里的 stop 又会把它挂回来(见脚本的 umount 步骤)。
|
||
// 判据 TestPreflightReportsKnownRisks 就是为了钉住这条。
|
||
if _, err := os.Stat(filepath.Join(dataDir, "agentfs")); err == nil {
|
||
merged := filepath.Join(dataDir, "agentfs", "merged")
|
||
if isMountpoint(merged) {
|
||
rep.Mounts = append(rep.Mounts, merged)
|
||
}
|
||
rep.Risks = append(rep.Risks,
|
||
"agentfs/merged 是 overlay 挂载点(lower/upper/work 三段):不先 umount 会搬走'空壳',"+
|
||
"真正内容仍挂在旧路径 ⇒ 新目录 agentfs 不可用。"+
|
||
"注意:内核启动时才挂它,所以**停机体检看不到挂载**也仍要卸")
|
||
}
|
||
|
||
// ② 数据库绝对路径:不改会"看起来记忆全丢"(内核按旧路径找不到就重建空库,且不报错)
|
||
rep.DBPathKeys = []string{
|
||
"core.daemon.data_dir", "core.plugin.dir", "core.memory.graph",
|
||
"core.memory.text", "core.memory.documents", "core.knowledge.path",
|
||
"core.log.path", "core.memory.multimodal_space.options.model_dir",
|
||
}
|
||
rep.Risks = append(rep.Risks,
|
||
fmt.Sprintf("数据库有 %d 个键存绝对路径;不改写会导致重启后按旧路径找数据,"+
|
||
"找不到就**静默重建空库**(看起来像记忆全丢)", len(rep.DBPathKeys)),
|
||
"plugin_data 下实测有 46 个文件写死了绝对路径(典型:commondev 的 git 镜像/workdir)",
|
||
"security.key 不迁 ⇒ 所有加密配置解不开(敏感信息全部失效)",
|
||
)
|
||
|
||
// ③ 是否同设备:决定 rename(瞬时)还是 rsync(几分钟)
|
||
if target != "" {
|
||
if src, err := os.Stat(dataDir); err == nil {
|
||
if dst, err := os.Stat(filepath.Dir(target)); err == nil {
|
||
a := src.Sys().(*syscall.Stat_t).Dev
|
||
b := dst.Sys().(*syscall.Stat_t).Dev
|
||
rep.SameDevice = a == b
|
||
rep.CanRename = rep.SameDevice
|
||
if !rep.SameDevice {
|
||
rep.Risks = append(rep.Risks,
|
||
"目标不在同一设备 ⇒ 不能用 rename,必须 rsync 全量拷贝(耗时随数据量增长)")
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ④ 当前 systemd 权威 vs DB 权威是否一致(两个权威不等价)
|
||
// 运行时权威 = -data(本模块的 dataDir);配置权威 = DB core.daemon.data_dir
|
||
if st, err := systemd.Check(dataDir, dataDir); err == nil {
|
||
if st.RuntimeDataDir != "" && st.ConfigDataDir != "" && st.RuntimeDataDir != st.ConfigDataDir {
|
||
rep.Risks = append(rep.Risks,
|
||
fmt.Sprintf("★ 数据目录有**两个权威且不一致**:-data=%s(管 memory/graph.db + 插件 DataDir)"+
|
||
" vs DB core.daemon.data_dir=%s(管 memory/text、knowledge、nlp 等 6 个模块)⇒ 数据会分裂",
|
||
st.RuntimeDataDir, st.ConfigDataDir))
|
||
}
|
||
}
|
||
return rep, nil
|
||
}
|
||
|
||
// dirSize 递归统计目录大小(符号链接不跟随,避免重复计算硬链接/环)。
|
||
func dirSize(root string) int64 {
|
||
var total int64
|
||
filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
|
||
if err != nil || info == nil {
|
||
return nil
|
||
}
|
||
if info.Mode()&os.ModeSymlink != 0 {
|
||
return nil
|
||
}
|
||
if !info.IsDir() {
|
||
total += info.Size()
|
||
}
|
||
return nil
|
||
})
|
||
return total
|
||
}
|
||
|
||
// isMountpoint 判断路径是否为挂载点(读 /proc/mounts,不依赖外部命令)。
|
||
func isMountpoint(p string) bool {
|
||
data, err := os.ReadFile("/proc/mounts")
|
||
if err != nil {
|
||
return false
|
||
}
|
||
clean := filepath.Clean(p)
|
||
for _, line := range splitLines(string(data)) {
|
||
f := splitFields(line)
|
||
if len(f) >= 2 && unescapeMount(f[1]) == clean {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func splitLines(s string) []string {
|
||
var out []string
|
||
start := 0
|
||
for i := 0; i < len(s); i++ {
|
||
if s[i] == '\n' {
|
||
out = append(out, s[start:i])
|
||
start = i + 1
|
||
}
|
||
}
|
||
if start < len(s) {
|
||
out = append(out, s[start:])
|
||
}
|
||
return out
|
||
}
|
||
|
||
func splitFields(s string) []string {
|
||
var out []string
|
||
i := 0
|
||
for i < len(s) {
|
||
for i < len(s) && s[i] == ' ' {
|
||
i++
|
||
}
|
||
j := i
|
||
for j < len(s) && s[j] != ' ' {
|
||
j++
|
||
}
|
||
if j > i {
|
||
out = append(out, s[i:j])
|
||
}
|
||
i = j
|
||
}
|
||
return out
|
||
}
|
||
|
||
// unescapeMount 还原 /proc/mounts 里的八进制转义(空格写作 \040)。
|
||
func unescapeMount(s string) string {
|
||
out := make([]byte, 0, len(s))
|
||
for i := 0; i < len(s); i++ {
|
||
if s[i] == '\\' && i+3 < len(s) {
|
||
v := 0
|
||
ok := true
|
||
for k := 1; k <= 3; k++ {
|
||
c := s[i+k]
|
||
if c < '0' || c > '7' {
|
||
ok = false
|
||
break
|
||
}
|
||
v = v*8 + int(c-'0')
|
||
}
|
||
if ok {
|
||
out = append(out, byte(v))
|
||
i += 3
|
||
continue
|
||
}
|
||
}
|
||
out = append(out, s[i])
|
||
}
|
||
return string(out)
|
||
}
|