// Package store 提供后端持久化:默认档为嵌入式 SQLite(单文件,零外部服务)。 // 存储档接口按设计 4.1/D16 预留 PostgreSQL 档;业务层只依赖本包公开方法。 package store import ( "database/sql" "encoding/json" "fmt" "os" "path/filepath" "time" _ "modernc.org/sqlite" ) // Store 持有数据库连接与对象目录。所有方法并发安全(SQLite 单写者,靠 busy 重试)。 type Store struct { db *sql.DB dataDir string // 对象目录:data/artifacts/ } // Open 打开(必要时创建)SQLite 存储并执行迁移。 func Open(path string) (*Store, error) { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return nil, err } dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)", path) db, err := sql.Open("sqlite", dsn) if err != nil { return nil, err } // SQLite 单写者:串行化写连接,避免 database is locked。 db.SetMaxOpenConns(1) s := &Store{db: db, dataDir: filepath.Join(filepath.Dir(path), "artifacts")} if err := s.migrate(); err != nil { db.Close() return nil, err } if err := os.MkdirAll(s.dataDir, 0o755); err != nil { db.Close() return nil, err } return s, nil } func (s *Store) Close() error { return s.db.Close() } func (s *Store) migrate() error { stmts := []string{ `CREATE TABLE IF NOT EXISTS projects ( id TEXT PRIMARY KEY, name TEXT NOT NULL, settings TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`, `CREATE TABLE IF NOT EXISTS conversations ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL DEFAULT '新会话', profile_id TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, FOREIGN KEY(project_id) REFERENCES projects(id) ON DELETE CASCADE)`, `CREATE TABLE IF NOT EXISTS messages ( id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL, run_id TEXT NOT NULL DEFAULT '', role TEXT NOT NULL, parts TEXT NOT NULL, seq INTEGER NOT NULL, created_at TEXT NOT NULL, FOREIGN KEY(conversation_id) REFERENCES conversations(id) ON DELETE CASCADE)`, `CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_id, seq)`, `CREATE TABLE IF NOT EXISTS runs ( id TEXT PRIMARY KEY, parent_run_id TEXT NOT NULL DEFAULT '', conversation_id TEXT NOT NULL, project_id TEXT NOT NULL, kind TEXT NOT NULL, profile_id TEXT NOT NULL DEFAULT '', status TEXT NOT NULL, phase TEXT NOT NULL DEFAULT '', agent_name TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, ended_at TEXT, usage TEXT, error TEXT)`, `CREATE INDEX IF NOT EXISTS idx_runs_conv ON runs(conversation_id)`, `CREATE INDEX IF NOT EXISTS idx_runs_parent ON runs(parent_run_id)`, `CREATE TABLE IF NOT EXISTS events ( seq INTEGER PRIMARY KEY AUTOINCREMENT, conversation_id TEXT NOT NULL, run_id TEXT NOT NULL DEFAULT '', ts TEXT NOT NULL, v INTEGER NOT NULL, type TEXT NOT NULL, payload TEXT)`, `CREATE INDEX IF NOT EXISTS idx_events_conv ON events(conversation_id, seq)`, `CREATE TABLE IF NOT EXISTS artifacts ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL, name TEXT NOT NULL, kind TEXT NOT NULL, mime TEXT NOT NULL DEFAULT '', size INTEGER NOT NULL DEFAULT 0, source_run TEXT NOT NULL DEFAULT '', content_path TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL)`, `CREATE TABLE IF NOT EXISTS hitl_cards ( id TEXT PRIMARY KEY, run_id TEXT NOT NULL, conversation_id TEXT NOT NULL, payload TEXT NOT NULL, created_at TEXT NOT NULL, resolved INTEGER NOT NULL DEFAULT 0, resolution TEXT)`, `CREATE TABLE IF NOT EXISTS profiles ( id TEXT PRIMARY KEY, builtin INTEGER NOT NULL DEFAULT 0, payload TEXT NOT NULL)`, `CREATE TABLE IF NOT EXISTS settings ( scope TEXT PRIMARY KEY, "values" TEXT NOT NULL)`, `CREATE TABLE IF NOT EXISTS plugins ( id TEXT PRIMARY KEY, dir TEXT NOT NULL, manifest TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, loaded_at TEXT NOT NULL)`, `CREATE TABLE IF NOT EXISTS plans ( conversation_id TEXT PRIMARY KEY, payload TEXT NOT NULL)`, `CREATE TABLE IF NOT EXISTS todos ( conversation_id TEXT PRIMARY KEY, payload TEXT NOT NULL)`, `CREATE TABLE IF NOT EXISTS knowledge ( id TEXT PRIMARY KEY, project_id TEXT NOT NULL DEFAULT '', source TEXT NOT NULL, title TEXT NOT NULL, ref TEXT NOT NULL DEFAULT '', chunk TEXT NOT NULL, created_at TEXT NOT NULL)`, `CREATE VIRTUAL TABLE IF NOT EXISTS knowledge_fts USING fts5(title, chunk, content='knowledge', content_rowid='rowid')`, `CREATE TRIGGER IF NOT EXISTS knowledge_ai AFTER INSERT ON knowledge BEGIN INSERT INTO knowledge_fts(rowid, title, chunk) VALUES (new.rowid, new.title, new.chunk); END`, `CREATE TRIGGER IF NOT EXISTS knowledge_ad AFTER DELETE ON knowledge BEGIN INSERT INTO knowledge_fts(knowledge_fts, rowid, title, chunk) VALUES('delete', old.rowid, old.title, old.chunk); END`, } for _, q := range stmts { if _, err := s.db.Exec(q); err != nil { return fmt.Errorf("migrate: %w", err) } } return nil } // ---- 通用 ---- func nowISO() string { return time.Now().UTC().Format(time.RFC3339Nano) } func marshalJSON(v any) string { b, _ := json.Marshal(v) return string(b) } func unmarshalJSON[T any](raw string) (T, error) { var out T err := json.Unmarshal([]byte(raw), &out) return out, err } var _ = unmarshalJSON[map[string]any]