// Package systemd 负责「数据库配置 ↔ systemd 单元」的**双向一致性**。 // // ## 要解决的问题(用户 2026-10-17 指出) // // 内核有两处"数据目录"来源,且**不等价**: // // -data ← systemd 单元,决定 ks.DataDir // core.daemon.data_dir ← 数据库,决定 ks.Cfg.Daemon.DataDir // // 实测(把副本 DB 的 data_dir 改成 /tmp/DB_SAYS,-data 仍给 /tmp/ddtest): // // 知识库迁移扫描失败: open /tmp/DB_SAYS/knowledge: no such file // text memory active at /tmp/DB_SAYS/memory/text // // 而 ks.DataDir(-data)管的是 memory/graph.db(数据最多的图记忆)与 // 插件 SettingsAPI.DataDir()。⇒ **两者不一致时数据会分裂**:图记忆在 A、 // 文本/知识/文档在 B,检索跨目录、备份会漏。 // // 生产当前两者都是 /home/newqqagent,所以没暴露。但 WebUI / CLI 允许改 // core.daemon.data_dir 而**没有任何提示** —— 用户改完重启就会发现数据"丢了"。 // // ## 本包的职责 // // 1. 检测不一致(Check) // 2. 生成**可以直接复制执行**的修复命令(SyncCommand) // // ## ★ 关键设计:命令必须内联替换,不让用户手工编辑 // // 用户明确要求: // // 「指令应当直接包含替换部分而不是让用户手动修改」 // // 所以生成的是**一条幂等的 sed 原地替换 + daemon-reload + restart**, // 而不是"请编辑文件把 -data 改成 xxx"。理由: // // · 手工编辑会漏掉 drop-in(生产用 drop-in 覆盖 ExecStart,改主单元无效) // · 手工编辑容易把 --role=guard 一起改坏 // · 一条命令可审计、可复制、可回滚(带 .bak) package systemd import ( "fmt" "os" "os/exec" "path/filepath" "regexp" "strings" ) // UnitName 是内核的服务单元名。 const UnitName = "homeagent.service" // State 描述一次一致性检查的结果。 type State struct { // RuntimeDataDir 是当前进程**实际**用的数据目录(来自 -data)。 RuntimeDataDir string // ConfigDataDir 是数据库里 core.daemon.data_dir 的值。 ConfigDataDir string // UnitPath 是主单元文件路径(通常 /etc/systemd/system/homeagent.service)。 UnitPath string // UnitDataDir 从单元文件里解析出的 -data 值(可能为空=没解析到)。 UnitDataDir string // DropIns 是覆盖 ExecStart 的 drop-in 路径(非空时改主单元无效)。 DropIns []string } // InSync 报告运行期与数据库配置是否一致。 func (s State) InSync() bool { return s.RuntimeDataDir == "" || s.ConfigDataDir == "" || filepath.Clean(s.RuntimeDataDir) == filepath.Clean(s.ConfigDataDir) } // Describe 给出人类可读的诊断(用于启动日志 / WebUI 提示)。 func (s State) Describe() string { if s.InSync() { return "" } return fmt.Sprintf( "数据目录不一致:内核按 -data 用的是 %q,而数据库 core.daemon.data_dir 是 %q。\n"+ "这两者**不等价**:\n"+ " · -data → memory/graph.db(图记忆)、插件数据根目录\n"+ " · core.daemon.data_dir → memory/text、memory/documents、knowledge、nlp、adapters、agentfs\n"+ "⇒ 不一致会让数据分裂,检索跨目录、备份会漏。", s.RuntimeDataDir, s.ConfigDataDir) } // Check 探测当前一致性。任何一步失败都返回已收集到的部分信息 + 错误, // 由调用方决定"降级为只告警"还是"当失败处理"。 func Check(runtimeDataDir, configDataDir string) (State, error) { st := State{RuntimeDataDir: runtimeDataDir, ConfigDataDir: configDataDir} unitPath := filepath.Join("/etc/systemd/system", UnitName) if _, err := os.Stat(unitPath); err == nil { st.UnitPath = unitPath if v := parseDataFlagFromFile(unitPath); v != "" { st.UnitDataDir = v } } else { // 发行版可能装在 /lib/systemd/system alt := filepath.Join("/lib/systemd/system", UnitName) if _, err := os.Stat(alt); err == nil { st.UnitPath = alt st.UnitDataDir = parseDataFlagFromFile(alt) } } // drop-in 里若有 `ExecStart=`(清空)后重设,改主单元无效 —— 必须一并处理。 dropDir := filepath.Join("/etc/systemd/system", UnitName+".d") if entries, err := os.ReadDir(dropDir); err == nil { for _, e := range entries { if e.IsDir() || !strings.HasSuffix(e.Name(), ".conf") { continue } p := filepath.Join(dropDir, e.Name()) if b, err := os.ReadFile(p); err == nil { if reExecStartReset.Match(b) { st.DropIns = append(st.DropIns, p) } } } } return st, nil } var ( reDataFlag = regexp.MustCompile(`(?:^|\s)-data[= ]([^\s"']+)`) reExecStartLn = regexp.MustCompile(`(?m)^\s*ExecStart\s*=.*$`) reExecStartReset = regexp.MustCompile(`(?m)^\s*ExecStart\s*=\s*$`) ) func parseDataFlagFromFile(path string) string { b, err := os.ReadFile(path) if err != nil { return "" } if m := reDataFlag.FindSubmatch(b); m != nil { return string(m[1]) } return "" } // SyncCommand 生成**可直接复制执行**的修复命令。 // // ## 为什么是一条内联 sed 而不是"请编辑文件" // // 用户明确要求「指令应当直接包含替换部分」。所以这里把新值直接拼进命令: // // sudo sed -i.bak 's|-data [^ ]*|-data /新路径|' /etc/systemd/system/homeagent.service // sudo systemctl daemon-reload && sudo systemctl restart homeagent // // 特性: // // · **幂等**:再跑一次结果相同 // · **带 .bak**:可回滚(cp xxx.bak xxx) // · **只替换 -data 的值**,不碰 --role=guard 等其他参数 // · drop-in 里若重设了 ExecStart,会**指出具体文件**并同样给出 sed 命令 // // want 为空时用 ConfigDataDir。 func (s State) SyncCommand(want string) string { if want == "" { want = s.ConfigDataDir } want = strings.TrimSpace(want) if want == "" { return "" } var b strings.Builder b.WriteString("# 1) 把 systemd 单元的数据目录改成 ") b.WriteString(want) b.WriteString("(幂等,自动留 .bak)\n") if s.UnitPath != "" { fmt.Fprintf(&b, "sudo sed -i.bak -E 's|(-data[= ])[^ \"'\"']+|\\1%s|' %s\n", sedEscapeRepl(want), s.UnitPath) } // drop-in 覆盖了 ExecStart 的,必须**也**改它,否则主单元改了也没用。 for _, d := range s.DropIns { fmt.Fprintf(&b, "sudo sed -i.bak -E 's|(-data[= ])[^ \"'\"']+|\\1%s|' %s\n", sedEscapeRepl(want), d) } b.WriteString("\n# 2) 让 systemd 重新加载并重启(guard 会用新参数拉起 worker)\n") b.WriteString("sudo systemctl daemon-reload && sudo systemctl restart ") b.WriteString(strings.TrimSuffix(UnitName, ".service")) b.WriteString("\n") b.WriteString("\n# 3) 校验(应看到 ExecStart 里的 -data 已是新值)\n") b.WriteString("systemctl show ") b.WriteString(UnitName) b.WriteString(" -p ExecStart --no-pager\n") b.WriteString("\n# 回滚(若需)\n") if s.UnitPath != "" { fmt.Fprintf(&b, "sudo cp %s.bak %s && sudo systemctl daemon-reload && sudo systemctl restart %s\n", s.UnitPath, s.UnitPath, strings.TrimSuffix(UnitName, ".service")) } return b.String() } // sedEscapeRepl 转义 sed 替换段里的特殊字符(分隔符用 |)。 func sedEscapeRepl(s string) string { s = strings.ReplaceAll(s, `\`, `\\`) s = strings.ReplaceAll(s, "|", `\|`) s = strings.ReplaceAll(s, "&", `\&`) return s } // RestartCommand 返回「热更新」命令:只重启(不动 systemd 配置)。 // // 与 SyncCommand 的区别:配置没变、只换了二进制时用这个。 // 走 WebUI 的 agent restart 端点即可(exit 42 → guard 用新二进制重拉), // 这里给出不依赖 WebUI 的等价 curl。 func RestartCommand(addr, apiKey string) string { return fmt.Sprintf(`curl -sS -X POST \ -H "X-API-Key: %s" \ "http://%s/api/v1/agents/agent/restart"`, apiKey, addr) } // IsUnderSystemd 报告当前进程是否由 systemd 拉起(用于决定是否提示提权)。 func IsUnderSystemd() bool { // INVOCATION_ID 由 systemd 注入,是最可靠的判据(容器里也可能有 systemd)。 if os.Getenv("INVOCATION_ID") != "" { return true } // 回退:父进程是 pid 1 且 /run/systemd/system 存在。 if _, err := os.Stat("/run/systemd/system"); err != nil { return false } return os.Getppid() == 1 } // HasSudo 报告当前进程能否免密 sudo(决定提示里要不要写 sudo)。 func HasSudo() bool { if os.Geteuid() == 0 { return true } if _, err := exec.LookPath("sudo"); err != nil { return false } return exec.Command("sudo", "-n", "true").Run() == nil }