Files
HomeAgent/internal/kernelmod/migration/mod.go
JianFeeeee 4e70b8d5d8 refactor(module): 核心仓 Go module 路径改为 github.com/JianFeeeee/HologramAgent
批 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 后缀
2026-10-21 12:17:19 +08:00

285 lines
9.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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)
}