package engine
// file_scratchpad.go 实现文件系统持久化的 Scratchpad.
//
// 模块定位:
//
// FileScratchpad 是 in-memory Scratchpad 的"跨进程"版本.
// 当多个 Worker 运行在不同进程中时(如 Platform 层的 DaemonManager),
// 它们通过共同指向同一目录的 FileScratchpad 共享暂存数据.
//
// 数据格式:
//
// 每个条目存储为
/.json,内容:
// {"k":"original_key","v":"value","e":"2024-01-01T00:00:00Z"}
// e="" 表示永不过期(与 in-memory Scratchpad 的零值语义一致).
//
// 并发模型:
// - 同进程并发:通过 sync.RWMutex 保护(与 in-memory 实现相同)
// - 跨进程并发:原子写入(临时文件 + os.Rename)保证文件级别原子性
// 注意:跨进程同时写同一 key 仍有竞态--最后一次 Rename 赢.
// 对 Scratchpad 的最终一致性语义(非银行转账)来说,这是可接受的权衡.
//
// 升华改进(ELEVATED): 早期实现 无持久化 Scratchpad 概念.
//
// in-memory Scratchpad 绑定单个 Engine 实例,无法跨进程共享.
// FileScratchpad 实现相同的 ScratchpadStore 接口,可以无缝替换--
// Platform 层配置 ScratchpadDir 后,多个 Worker 进程透明共享暂存数据.
// 跨行业影响:
// 仓储:多个 picking-robot Worker 共享"当前批次已分配库位"
// 金融:多个 reconcile Worker 共享"已处理交易 ID 集合"
// 医疗:诊断 Agent 和记录 Agent 共享"当前问诊结论"
// 替代方案: - 否决:破坏零外部依赖原则;文件系统已足够简单可靠.
import (
"crypto/sha256"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
// fileScratchEntry 是文件中存储的 JSON 结构.
//
// 精妙之处(CLEVER): 保存 key(k)而非只保存 value--
// Keys() 扫描目录时需要知道原始 key,而不是 SHA-256 哈希.
// 如果只存 value,就需要维护独立的索引文件(增加原子性复杂度).
type fileScratchEntry struct {
K string `json:"k"` // 原始 key(用于 Keys() 还原)
V string `json:"v"` // 值
E string `json:"e,omitempty"` // 过期时间 RFC3339Nano;空 = 永不过期
}
// FileScratchpad 是基于文件系统的 Scratchpad 实现.
//
// 实现 builtin.ScratchpadStore 接口,可作为 in-memory Scratchpad 的直接替代.
// 数据持久化到目录,支持跨进程共享.
type FileScratchpad struct {
dir string
mu sync.RWMutex // 同进程并发保护
}
// NewFileScratchpad 创建文件系统持久化的 Scratchpad.
//
// 参数 dir 是存储目录路径(会自动创建,如果不存在).
// 调用方应使用会话级别的专用目录,不同会话不应共享同一目录
// (除非有意共享状态,如 Team 协调器场景).
func NewFileScratchpad(dir string) (*FileScratchpad, error) {
if err := os.MkdirAll(dir, 0700); err != nil {
return nil, fmt.Errorf("file_scratchpad: create dir %q: %w", dir, err)
}
return &FileScratchpad{dir: dir}, nil
}
// keyToFilename 将任意 key 转换为安全的文件名.
//
// 精妙之处(CLEVER): SHA-256 哈希作为文件名--
//
// (1) 任意 Unicode/特殊字符 key(包含 / .. 等路径元素)都能安全映射
// (2) 固定长度(64 hex 字符)避免文件名过长
// (3) 无需担心文件系统大小写折叠(hex 字符全小写)
// (4) 碰撞概率 2^-256 可忽略
//
// 替代方案:
// - 否决:URL 编码后 "/" 变 "%2F",仍需额外防路径穿越;
// 长 key 超过文件系统限制(通常 255 字节);调试时不直观但可接受.
func keyToFilename(key string) string {
h := sha256.Sum256([]byte(key))
return fmt.Sprintf("%x.json", h)
}
// Set 写入键值对到文件(原子写入,可选 TTL).
//
// ttl=0 表示永不过期;ttl>0 表示过期时长.
// 已有同 key 文件时覆盖(刷新 TTL).
func (s *FileScratchpad) Set(key, value string, ttl time.Duration) {
s.mu.Lock()
defer s.mu.Unlock()
entry := fileScratchEntry{K: key, V: value}
if ttl > 0 {
entry.E = time.Now().Add(ttl).Format(time.RFC3339Nano)
}
data, err := json.Marshal(entry)
if err != nil {
// JSON 序列化失败几乎不可能(除非 value 包含非法 UTF-8)
// 遵循 ScratchpadStore 接口不返回 error 的约定--静默失败.
return
}
// 原子写入:先写临时文件,再 Rename 到目标路径
//
// 精妙之处(CLEVER): 原子替换(Rename)防止读操作看到半写状态--
// 若直接 os.WriteFile,在写入过程中另一个 goroutine/进程读到的是损坏的 JSON.
// os.Rename 在同一文件系统内是原子操作(POSIX 保证).
// 替代方案:<直接 os.WriteFile> - 否决:写入过程中崩溃/并发读会看到损坏数据.
target := filepath.Join(s.dir, keyToFilename(key))
tmp := target + ".tmp"
if err := os.WriteFile(tmp, data, 0600); err != nil {
return // 写临时文件失败,静默失败
}
if err := os.Rename(tmp, target); err != nil {
fmt.Fprintf(os.Stderr, "file_scratchpad: rename failed: %v\n", err)
}
}
// Get 读取指定 key 的值.
//
// 返回 (value, true) 如果存在且未过期;返回 ("", false) 否则.
// 过期条目在 Get 时惰性删除(与 in-memory 实现一致).
func (s *FileScratchpad) Get(key string) (string, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
path := filepath.Join(s.dir, keyToFilename(key))
data, err := os.ReadFile(path)
if err != nil {
return "", false // 文件不存在或读取失败
}
var entry fileScratchEntry
if err := json.Unmarshal(data, &entry); err != nil {
return "", false // 文件损坏
}
// 检查 TTL
if entry.E != "" {
expiresAt, err := time.Parse(time.RFC3339Nano, entry.E)
if err != nil {
return "", false // 过期时间格式错误,视为已过期
}
if time.Now().After(expiresAt) {
// 惰性删除:直接删除文件(读锁保护下只需要原子读+删除,无须升级锁)
//
// 精妙之处(CLEVER): 与 in-memory Scratchpad.Get() 的惰性删除策略一致,
// 不启动后台清理 goroutine.
// 对文件操作而言,os.Remove 是原子的--
// 多个并发 goroutine 同时删除同一文件时,第一个成功,其余得到 "no such file"(忽略).
// 持有 RLock 仍然可以调用 os.Remove(RLock 不阻止 os.Remove,
// 只阻止其他写操作更新 s.mu 保护的内存状态).
_ = os.Remove(path)
return "", false
}
}
return entry.V, true
}
// Delete 删除指定 key 的条目.
func (s *FileScratchpad) Delete(key string) {
s.mu.Lock()
defer s.mu.Unlock()
path := filepath.Join(s.dir, keyToFilename(key))
_ = os.Remove(path) // 文件不存在时 Remove 返回 error,忽略
}
// Keys 返回所有未过期 key 的有序列表.
//
// 扫描目录中的所有 .json 文件,读取原始 key,过滤已过期的条目.
// 过期文件在扫描时惰性删除.
//
// 精妙之处(CLEVER): 目录扫描 O(n) 是可接受的--
// Scratchpad 用于 Agent 的工作内存,通常只有数十个条目;
// n=100 条时扫描 + 读文件的延迟远低于一次 LLM API 调用.
// 替代方案:<维护内存索引> - 否决:增加内存-磁盘同步的复杂性,
//
// 进程重启后索引丢失(需要从磁盘重建,等价于扫描).
func (s *FileScratchpad) Keys() []string {
s.mu.Lock()
defer s.mu.Unlock()
entries, err := os.ReadDir(s.dir)
if err != nil {
return nil
}
now := time.Now()
var keys []string
var expired []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(name, ".json") {
continue
}
// 忽略临时文件(写入过程中的 .tmp 文件)
if strings.HasSuffix(name, ".tmp") {
continue
}
path := filepath.Join(s.dir, name)
data, err := os.ReadFile(path)
if err != nil {
continue
}
var fe fileScratchEntry
if err := json.Unmarshal(data, &fe); err != nil {
continue // 损坏文件跳过
}
// 检查是否过期
if fe.E != "" {
expiresAt, err := time.Parse(time.RFC3339Nano, fe.E)
if err != nil || now.After(expiresAt) {
expired = append(expired, path)
continue
}
}
if fe.K != "" {
keys = append(keys, fe.K)
}
}
// 惰性清理过期文件(在持有锁时同步删除,避免额外 goroutine)
for _, path := range expired {
_ = os.Remove(path)
}
// 排序保证输出稳定(与 in-memory Scratchpad.Keys() 一致)
sort.Strings(keys)
return keys
}