mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-27 21:03:16 +00:00
store.go:
· **存 AST 不存文本**。执行期不重新解析原始文本 ⇒ 一次格式改动不会
悄悄改变已保存序列的行为。
· 先写 .tmp 再 rename,避免写一半被读。
· **路径穿越防护**:序列名来自模型且被直接拼进文件路径,不校验的话
`seq_load("../secret")` 能读任意文件、`seq_delete` 能删任意文件。
· CheckGraph:跨序列调用的**目标存在性** + **环检测**(三色 DFS),
报错时给出**环路径**(#A → #B → #A),便于定位。
· maxCallDepth = 4 是**结构常量**不是配置项 —— 沿用内核
MaxInterruptFrames 的做法(core/scheduler.go:271「结构上界,不是配置项」):
上界一旦可配,总有人会把它调到栈溢出。
exec.go 补 missing 策略(动态注册下「工具不存在」是**常态**):
· fail(默认)/ skip / degrade,与「执行失败」严格分开
· ⚠️ missing 分支**必须先于**通用 on_error 检查:否则「插件挂了」会被
on_error=abort 连坐整组中断,skip/degrade 形同虚设
· skip 时**不赋值槽**(与「条件为假」同一情形,下游要能应对槽缺失)
· 本包自带 errToolNotFound 哨兵而**不复用** io 包的同名错误:seq 是插件,
拿得到 sdk.ToolAPI,拿不到 io 包类型(见设计文档 §7 边界声明)
★ 过程中解决一个**设计死锁**(值得单列):
我最初让 Save 校验「跨序列目标必须已存在」。但互调的两条序列
谁也存不下来——A 要 B 先在、B 要 A 先在,**依赖在设计上无解**。
⇒ Save 只校验**同序列内**的 group 引用(那部分信息自足);
跨序列目标的存在性与环由 CheckGraph 在保存后统一兜底。
判据与实现都写明了这个分工的理由。
判据(store_test.go,7 条):
· 存取往返保住 AST(含 out 声明——它是签名的一部分)
· 列表 / 删除;删不存在的**报错**(不静默成功,模型会以为删掉了)
· ★ 跨序列成环被拒且错误含环路径;无环通过
· maxCallDepth 是正的结构常量
· ★ missing 三种取值各有明确行为
· ★ 路径穿越:7 种恶意名既读不到也删不掉,且**在 store 目录外**放真实
文件断言它仍在(不是"读代码看着对",是跑出来的)
过程中三次自伤:
1. 序列名我写成 "#A"/"#B"——`#` 只是 target 里的前缀标记,
落盘名不带它,于是 CheckGraph 找不到、误报「不存在」。
2. missing 策略与 on_error 检查的**顺序**反了,导致 skip/degrade 被
abort 连坐(判据直接暴露)。
3. 为压掉 unused import 写了 `var _ = os.Remove` 这种占位 hack ——
正是检查项 go-ignored-call-result 指出的那类东西,已删;
另把 rename 失败分支的 `os.Remove(tmp)` 加上注释说明
「清理失败有意忽略,否则会盖掉真正的失败原因」。
变异验证:去掉环检测(三色 DFS 全放行)⇒ 成环判据 FAIL
("A→B→A 成环却通过检查")。
回归:-race 下 seq 全绿;internal/plugins/... 全绿。
core 包偶发 TestResidualKeep 失败是**已记录的既有竞态**
(offload_test.go 的 SpawnResident 起了子调度器而测试无同步就读队列),
与本阶段无关,已在执行计划中记为待修。
280 lines
8.3 KiB
Go
280 lines
8.3 KiB
Go
package seq
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
)
|
||
|
||
// maxCallDepth 是**嵌套调用的结构上界**,不是配置项。
|
||
//
|
||
// 沿用内核 MaxInterruptFrames 的做法(见 core/scheduler.go:271「是中断栈帧数的
|
||
// 结构上界,不是配置项」):上界一旦可配,总有人会把它调大到栈溢出。
|
||
//
|
||
// 取 4 与内核的 4 级中断一致。
|
||
const maxCallDepth = 4
|
||
|
||
// Store 负责序列的持久化:**存 AST,不存文本**。
|
||
//
|
||
// 为什么不存原始文本:执行期若重新解析文本,一次格式改动就会改变已保存
|
||
// 序列的行为;存 AST 则解析只发生在创建时,注释/空白/引号形式在 AST
|
||
// 层面已消失,不引入执行期差异。
|
||
type Store struct {
|
||
dir string
|
||
mu sync.RWMutex
|
||
}
|
||
|
||
// NewStore 在 dir 下管理序列文件(不创建目录,由 Save 惰性创建)。
|
||
func NewStore(dir string) *Store { return &Store{dir: dir} }
|
||
|
||
// fileOf 返回某序列的落盘路径。
|
||
func (s *Store) fileOf(name string) string {
|
||
return filepath.Join(s.dir, name+".json")
|
||
}
|
||
|
||
// Save 落盘一条序列的 AST。
|
||
func (s *Store) Save(seq *Sequence) error {
|
||
if seq == nil || strings.TrimSpace(seq.Name) == "" {
|
||
return fmt.Errorf("序列缺少 name")
|
||
}
|
||
if err := s.checkName(seq.Name); err != nil {
|
||
return err
|
||
}
|
||
if err := os.MkdirAll(s.dir, 0755); err != nil {
|
||
return fmt.Errorf("创建序列目录失败: %w", err)
|
||
}
|
||
b, err := json.MarshalIndent(seq, "", " ")
|
||
if err != nil {
|
||
return fmt.Errorf("序列化序列 %q 失败: %w", seq.Name, err)
|
||
}
|
||
// 静态校验:同序列内的 group 引用必须存在、不得自调用。
|
||
// 跨序列目标的存在性由 CheckGraph 统一查(此时新序列还没落盘)。
|
||
if err := s.CheckNew(seq); err != nil {
|
||
return err
|
||
}
|
||
// 先写临时文件再 rename:避免写一半被读(与内核原子替换同一思路)
|
||
tmp := s.fileOf(seq.Name) + ".tmp"
|
||
if err := os.WriteFile(tmp, b, 0644); err != nil {
|
||
return fmt.Errorf("写序列 %q 失败: %w", seq.Name, err)
|
||
}
|
||
if err := os.Rename(tmp, s.fileOf(seq.Name)); err != nil {
|
||
// 清理失败**有意忽略**:rename 已失败,再报一个清理错误只会
|
||
// 盖掉真正的失败原因(这正是 rename 失败要暴露的那条)。
|
||
// 残留的 .tmp 由下次 Save 覆盖。
|
||
_ = os.Remove(tmp)
|
||
return fmt.Errorf("替换序列 %q 失败: %w", seq.Name, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Load 读回一条序列的 AST。
|
||
func (s *Store) Load(name string) (*Sequence, error) {
|
||
if err := s.checkName(name); err != nil {
|
||
return nil, err
|
||
}
|
||
b, err := os.ReadFile(s.fileOf(name))
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
return nil, fmt.Errorf("序列 %q 不存在(用 seq_list 看可用序列)", name)
|
||
}
|
||
return nil, fmt.Errorf("读序列 %q 失败: %w", name, err)
|
||
}
|
||
var seq Sequence
|
||
dec := json.NewDecoder(strings.NewReader(string(b)))
|
||
dec.DisallowUnknownFields()
|
||
if err := dec.Decode(&seq); err != nil {
|
||
return nil, fmt.Errorf("序列 %q 的存档损坏: %w", name, err)
|
||
}
|
||
return &seq, nil
|
||
}
|
||
|
||
// Delete 删除一条序列。不存在时报错(不静默成功 —— 模型会以为删掉了)。
|
||
func (s *Store) Delete(name string) error {
|
||
if err := s.checkName(name); err != nil {
|
||
return err
|
||
}
|
||
if err := os.Remove(s.fileOf(name)); err != nil {
|
||
if os.IsNotExist(err) {
|
||
return fmt.Errorf("序列 %q 不存在(用 seq_list 看可用序列)", name)
|
||
}
|
||
return fmt.Errorf("删除序列 %q 失败: %w", name, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// List 列出全部序列名(升序)。
|
||
func (s *Store) List() []string {
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
entries, err := os.ReadDir(s.dir)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
var out []string
|
||
for _, e := range entries {
|
||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
||
continue
|
||
}
|
||
out = append(out, strings.TrimSuffix(e.Name(), ".json"))
|
||
}
|
||
sort.Strings(out)
|
||
return out
|
||
}
|
||
|
||
// checkName 校验序列名:必须能安全用作文件名。
|
||
//
|
||
// ⚠️ 名字来自模型,且会被拼进路径(Load/Save/Delete 都用)⇒ 必须挡住
|
||
// 路径穿越(`../`)与分隔符,否则 `seq_load` 能读到任意文件。
|
||
func (s *Store) checkName(name string) error {
|
||
if strings.TrimSpace(name) == "" {
|
||
return fmt.Errorf("序列名不能为空")
|
||
}
|
||
if strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
|
||
return fmt.Errorf("序列名 %q 非法:不能包含路径分隔符或 ..", name)
|
||
}
|
||
if strings.HasPrefix(name, ".") {
|
||
return fmt.Errorf("序列名 %q 非法:不能以 . 开头", name)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// callTargets 返回某序列内所有 seq_call 的跨序列目标。
|
||
func callTargets(seq *Sequence) []string {
|
||
var out []string
|
||
for _, g := range seq.Groups {
|
||
for _, t := range g.Tools {
|
||
if t.Tool != "seq_call" && t.Tool != "seq_when_call" {
|
||
continue
|
||
}
|
||
if tgt, ok := t.Args["target"].(string); ok && strings.HasPrefix(tgt, "#") {
|
||
out = append(out, tgt)
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// CheckGraph 校验跨序列调用图。
|
||
//
|
||
// 两条检查(都必须在**建序列/保存**时做,而不是等运行):
|
||
// 1. 每个 `seq_call` 的目标必须存在(不存在会在运行期才发现,浪费一整轮)
|
||
// 2. 不得有环(否则无限嵌套,每层都真的在调工具)
|
||
func (s *Store) CheckGraph() error {
|
||
names := s.List()
|
||
seqs := make(map[string]*Sequence, len(names))
|
||
for _, n := range names {
|
||
seq, err := s.Load(n)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
seqs[n] = seq
|
||
}
|
||
// 目标存在性
|
||
for _, name := range names {
|
||
for _, tgt := range callTargets(seqs[name]) {
|
||
bare := strings.TrimPrefix(tgt, "#")
|
||
if _, ok := seqs[bare]; !ok {
|
||
return fmt.Errorf("序列 %q 调用了不存在的序列 %q(用 seq_list 看可用序列)", name, tgt)
|
||
}
|
||
}
|
||
}
|
||
// 环检测(三色 DFS),错误里带**环路径**便于定位
|
||
const (
|
||
white = 0 // 未访问
|
||
gray = 1 // 在栈上
|
||
black = 2 // 已完成
|
||
)
|
||
color := make(map[string]int, len(seqs))
|
||
var path []string
|
||
var dfs func(n string) error
|
||
dfs = func(n string) error {
|
||
color[n] = gray
|
||
path = append(path, "#"+n)
|
||
for _, tgt := range callTargets(seqs[n]) {
|
||
bare := strings.TrimPrefix(tgt, "#")
|
||
switch color[bare] {
|
||
case gray:
|
||
// 找到环:从 path 里第一次出现 bare 处截断,给出完整环
|
||
ring := path
|
||
for i, p := range path {
|
||
if p == tgt {
|
||
ring = path[i:]
|
||
break
|
||
}
|
||
}
|
||
return fmt.Errorf("跨序列调用成环: %s → %s",
|
||
strings.Join(ring, " → "), tgt)
|
||
case white:
|
||
if err := dfs(bare); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
path = path[:len(path)-1]
|
||
color[n] = black
|
||
return nil
|
||
}
|
||
for _, n := range names {
|
||
if color[n] == white {
|
||
if err := dfs(n); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// CheckNew 在**保存前**校验一条新序列:组内/跨序列引用是否存在。
|
||
//
|
||
// 分两步:先查**同序列内**的 seq_call 目标(组名)是否存在,再查
|
||
// **跨序列**目标是否已存在(存盘之后才能查全图,故由 Save 后的
|
||
// CheckGraph 负责)。
|
||
func (s *Store) CheckNew(seq *Sequence) error {
|
||
groupNames := map[string]bool{}
|
||
for _, g := range seq.Groups {
|
||
groupNames[g.Name] = true
|
||
}
|
||
for _, g := range seq.Groups {
|
||
for i, t := range g.Tools {
|
||
if t.Tool != "seq_call" && t.Tool != "seq_when_call" {
|
||
continue
|
||
}
|
||
tgt, _ := t.Args["target"].(string)
|
||
if strings.TrimSpace(tgt) == "" {
|
||
return fmt.Errorf("group %q 第 %d 个工具的 seq_call 缺少 target", g.Name, i+1)
|
||
}
|
||
if strings.HasPrefix(tgt, "#") {
|
||
bare := strings.TrimPrefix(tgt, "#")
|
||
if bare == seq.Name {
|
||
return fmt.Errorf("序列 %q 调用了自身(会造成无限递归)", seq.Name)
|
||
}
|
||
// ⚠️ 跨序列目标**不在这里**要求存在:互调的两条序列
|
||
// 谁也存不下来(A 要 B 先在、B 要 A 先在),是设计上死锁。
|
||
// 存在性与环统一由 CheckGraph 在保存后兜底。
|
||
continue
|
||
}
|
||
if !groupNames[tgt] {
|
||
return fmt.Errorf("group %q 第 %d 个工具调用了不存在的 group %q"+
|
||
"(本序列现有:%s)", g.Name, i+1, tgt, joinNames(groupNames))
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func joinNames(m map[string]bool) string {
|
||
if len(m) == 0 {
|
||
return "(无)"
|
||
}
|
||
out := make([]string, 0, len(m))
|
||
for k := range m {
|
||
out = append(out, k)
|
||
}
|
||
sort.Strings(out)
|
||
return strings.Join(out, ", ")
|
||
}
|