// plan_store.go - Plan Mode 文件存储层 // // 定位:模块 17 UltraPlan 的存储抽象,负责计划文件的读写和路径管理. // // 核心设计决策: // - PlanStore 接口解耦存储实现,CLI 用文件,SDK/API 用内存(测试友好) // - Word slug 文件名:human-readable(scenic-delta),防 UUID 满屏 // - 路径遍历防护:slug 只允许 [a-z0-9-],绝对不允许 ../ // - memoize 目录创建:getPlansDir 只 MkdirAll 一次(早期方案 memoize 策略) // // 对应早期方案 // // 升华改进(ELEVATED): 早期方案 将 slug 缓存,目录计算,文件读写混在同一层, // 且强依赖全局单例(getPlanSlugCache, getInitialSettings, getFsImplementation). // 我们抽出 PlanStore 接口:FilePlanStore 对应 CLI,MemoryPlanStore 对应 SDK/API 嵌入. // 跨行业部署时可换 DBPlanStore(Postgres 存计划),引擎核心不知道也不关心. package engine import ( "fmt" "math/rand" "os" "path/filepath" "strings" "sync" ) // PlanStore 抽象计划文件的存储. // // 精妙之处(CLEVER): 接口只有 3 个方法--Write/Read/Path. // 故意不暴露 List/Delete,计划文件生命周期由调用方(Engine)管理, // 不在存储层增加可观测的副作用. type PlanStore interface { // WritePlan 将计划内容写入持久化存储.sessionID 用于路由到正确的文件/记录. WritePlan(sessionID, content string) error // ReadPlan 读取计划内容.若不存在返回 ("", nil),不是 error. ReadPlan(sessionID string) (string, error) // PlanPath 返回计划的逻辑路径(用于展示给用户,不保证是真实文件系统路径). PlanPath(sessionID string) string } // ───────────────────────────────────────────── // FilePlanStore - CLI / 本地 SDK 实现 // ───────────────────────────────────────────── // FilePlanStore 将计划存储为本地文件,使用 word-slug 作为文件名. // 对应早期方案 的 getPlanFilePath / getPlan / writePlan 组合. type FilePlanStore struct { // Dir 是计划文件目录(通常 ~/.flyto/plans/). // 若为空,使用 os.TempDir()/flyto-plans/. Dir string mu sync.Mutex slugs map[string]string // sessionID → slug 缓存 dirOnce sync.Once dirErr error } // ensureDir 确保计划目录存在(只执行一次). func (s *FilePlanStore) ensureDir() error { s.dirOnce.Do(func() { dir := s.planDir() s.dirErr = os.MkdirAll(dir, 0o700) }) return s.dirErr } // planDir 返回实际使用的计划目录路径. func (s *FilePlanStore) planDir() string { if s.Dir != "" { return s.Dir } return filepath.Join(os.TempDir(), "flyto-plans") } // slugForSession 返回(或生成)给定会话的 word slug,加锁. // // 精妙之处(CLEVER): slug 一旦生成就固定在缓存里,同一会话多次调用 PlanPath/Write/Read // 总是指向同一个文件.如果不缓存,每次调用都生成不同的 slug,会话结束前都找不到计划文件. func (s *FilePlanStore) slugForSession(sessionID string) string { s.mu.Lock() defer s.mu.Unlock() if s.slugs == nil { s.slugs = make(map[string]string) } if slug, ok := s.slugs[sessionID]; ok { return slug } // 尝试生成不冲突的 slug(最多10次,和早期方案 MAX_SLUG_RETRIES=10 对齐) dir := s.planDir() var slug string for i := 0; i < 10; i++ { slug = generateWordSlug() candidate := filepath.Join(dir, slug+".md") if _, err := os.Stat(candidate); os.IsNotExist(err) { break } } s.slugs[sessionID] = slug return slug } // WritePlan 将 content 原子写入 {Dir}/{slug}.md. // // L1205 (2026-04-13): 早期方案直接 os.WriteFile, 注释声称"原子写入"但实际不是-- // 中途崩溃会留下截断文件, 并发 Read 也可能读到半写状态. // // 升华改进(ELEVATED): 改为 tmp file + os.Rename 真正原子写入. // POSIX 保证同文件系统 rename 原子, Read 永远看到"某次完整 Write 的结果", // 不会撞上半写.同时跨进程安全--另一个进程 (daemon / UI) 在 Read 时不需要锁. // // 替代方案 1: 加 sync.Mutex 锁 Write/Read -- 否决, 只能保护同进程, 解决不了跨进程场景, // 是"形似 MemoryPlanStore"的肤浅一致性, 不是"读到的内容总是某次完整 Write"的语义一致性. // 替代方案 2: 用文件锁 (flock) -- 过度工程, POSIX rename 足够且更简单. // // 精妙之处(CLEVER): tmp 文件与目标文件同目录 - 跨目录 rename 在 ext4/xfs 等主流文件 // 系统上也原子, 但"同目录 rename"是跨所有 POSIX 文件系统的最强保证 (EXDEV 错误排除). func (s *FilePlanStore) WritePlan(sessionID, content string) error { if err := s.ensureDir(); err != nil { return fmt.Errorf("plan_store: mkdir: %w", err) } // Defense-in-depth: slug 由 generateWordSlug 内部生成(天然 [a-z-]), // 此检查防范未来 slug 来源变更时的路径遍历风险. if err := validatePlanSlug(s.slugForSession(sessionID)); err != nil { return fmt.Errorf("plan_store: invalid slug: %w", err) } path := s.planFilePath(sessionID) // tmp file 与 target 同目录, 用 os.CreateTemp 的 pattern 得到唯一临时名. // 失败分支必须清理 tmp, 否则 plan 目录会积累孤儿文件. dir := filepath.Dir(path) tmp, err := os.CreateTemp(dir, ".plan-*.tmp") if err != nil { return fmt.Errorf("plan_store: create tmp: %w", err) } tmpPath := tmp.Name() // 清理策略: 任何后续失败都要 rm tmp 防止泄漏.成功 rename 后 tmp 已消失, 不会误删. cleanup := func() { _ = os.Remove(tmpPath) } if _, err := tmp.Write([]byte(content)); err != nil { tmp.Close() cleanup() return fmt.Errorf("plan_store: write tmp: %w", err) } // fsync 保证内容落盘, 防止断电后 rename 完成但内容未落盘的 "空文件" 状态. // 精妙之处(CLEVER): 只 fsync 文件不 fsync 目录 - 目录 fsync 昂贵且对 plan 文件 // 过度防御 (plan 丢失不是灾难, 只是要求用户重跑 plan 生成). if err := tmp.Sync(); err != nil { tmp.Close() cleanup() return fmt.Errorf("plan_store: sync tmp: %w", err) } if err := tmp.Close(); err != nil { cleanup() return fmt.Errorf("plan_store: close tmp: %w", err) } // 设置最终权限 (CreateTemp 默认 0600 已足够, 但显式 Chmod 保证跨平台一致). if err := os.Chmod(tmpPath, 0o600); err != nil { cleanup() return fmt.Errorf("plan_store: chmod tmp: %w", err) } // 原子 rename: 失败时 tmp 仍存在, 清理它. if err := os.Rename(tmpPath, path); err != nil { cleanup() return fmt.Errorf("plan_store: rename: %w", err) } return nil } // ReadPlan 读取计划内容.文件不存在时返回 ("", nil). func (s *FilePlanStore) ReadPlan(sessionID string) (string, error) { path := s.planFilePath(sessionID) data, err := os.ReadFile(path) if os.IsNotExist(err) { return "", nil } if err != nil { return "", fmt.Errorf("plan_store: read: %w", err) } return string(data), nil } // PlanPath 返回计划文件的完整路径(用于展示). func (s *FilePlanStore) PlanPath(sessionID string) string { return s.planFilePath(sessionID) } // planFilePath 组合 dir + slug + ".md". func (s *FilePlanStore) planFilePath(sessionID string) string { slug := s.slugForSession(sessionID) return filepath.Join(s.planDir(), slug+".md") } // ClearSession 删除给定会话的 slug 缓存条目(用于 /clear 命令). // 对应早期方案 clearPlanSlug(). func (s *FilePlanStore) ClearSession(sessionID string) { s.mu.Lock() defer s.mu.Unlock() if s.slugs != nil { delete(s.slugs, sessionID) } } // ───────────────────────────────────────────── // MemoryPlanStore - SDK/API 嵌入 / 测试用 // ───────────────────────────────────────────── // MemoryPlanStore 将计划存储在内存 map 中,适合 SDK 嵌入服务端或单元测试. // // 升华改进(ELEVATED): 早期方案没有内存实现--测试时必须 mock fs 或用真实文件系统. // 我们提供 MemoryPlanStore 让测试不需要 tmp 目录,SDK 嵌入 Web Server 时也避免 // 文件系统权限问题(容器 read-only rootfs 场景). type MemoryPlanStore struct { mu sync.RWMutex plans map[string]string // sessionID → content prefix string // 逻辑路径前缀,用于 PlanPath 展示 } // NewMemoryPlanStore 创建内存计划存储. // prefix 用于 PlanPath 展示,例如 "memory://plans". func NewMemoryPlanStore(prefix string) *MemoryPlanStore { if prefix == "" { prefix = "memory://plans" } return &MemoryPlanStore{ plans: make(map[string]string), prefix: prefix, } } func (m *MemoryPlanStore) WritePlan(sessionID, content string) error { m.mu.Lock() defer m.mu.Unlock() m.plans[sessionID] = content return nil } func (m *MemoryPlanStore) ReadPlan(sessionID string) (string, error) { m.mu.RLock() defer m.mu.RUnlock() return m.plans[sessionID], nil } // PlanPath 返回逻辑路径,格式 "{prefix}/{sessionID}". func (m *MemoryPlanStore) PlanPath(sessionID string) string { return m.prefix + "/" + sessionID } // ───────────────────────────────────────────── // Word Slug 生成器 // ───────────────────────────────────────────── // adjectives / nouns 构成 word slug(形容词-名词),可读性好且唯一性够用. // 早期方案 generateWordSlug() 格式相同. // // 精妙之处(CLEVER): 用单词组合而非 UUID--"scenic-delta.md" 比 // "3f7a2b91-....md" 对用户友好得多. // 组合数 = 40×40 = 1600,会话并发极低,冲突概率可忽略. // 如果要求更低碰撞概率可扩展单词表;生产中 FilePlanStore 会重试最多10次. var ( slugAdjectives = []string{ "amber", "arctic", "bold", "bright", "calm", "cedar", "clear", "crisp", "dawn", "deep", "delta", "eager", "echo", "elder", "ember", "fair", "fast", "fleet", "fresh", "frost", "glad", "green", "gold", "grand", "high", "keen", "light", "lunar", "mild", "noble", "open", "plain", "prime", "rapid", "sage", "scenic", "silver", "solar", "swift", "vast", } slugNouns = []string{ "arch", "blade", "brook", "cedar", "cliff", "coast", "crest", "dawn", "delta", "drift", "dune", "echo", "fern", "field", "flame", "fleet", "forge", "frost", "glade", "grove", "haven", "hearth", "helm", "hill", "inlet", "isle", "jade", "lake", "lance", "lark", "leaf", "ledge", "light", "mesa", "mist", "moon", "peak", "pine", "pond", "ridge", } ) // generateWordSlug 生成一个随机形容词-名词组合,例如 "scenic-delta". // 生成的 slug 只含 [a-z-],对路径遍历攻击天然免疫. // // 精妙之处(CLEVER): 字符集限制 [a-z-] 不是为了"好看"--而是硬性安全约束. // validatePlanSlug 会验证这个不变量,防止调用方注入 "../../../etc/passwd" 风格的 slug. func generateWordSlug() string { adj := slugAdjectives[rand.Intn(len(slugAdjectives))] noun := slugNouns[rand.Intn(len(slugNouns))] return adj + "-" + noun } // validatePlanSlug 验证 slug 只包含安全字符 [a-z0-9-],防止路径遍历. // 内部函数,FilePlanStore 在写路径前隐式保证(因为只用自己生成的 slug). // 外部如果要接受用户传入的 slug 必须先调用此函数. func validatePlanSlug(slug string) error { if slug == "" { return fmt.Errorf("plan_store: empty slug") } for _, ch := range slug { if !((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-') { return fmt.Errorf("plan_store: invalid slug char %q in %q", ch, slug) } } if strings.Contains(slug, "--") { return fmt.Errorf("plan_store: slug contains consecutive dashes: %q", slug) } return nil }