Files
Agent-System-Backend/internal/store/store.go
T
weichen 0d74ec94dd Implement the backend: store/bus/runmgr core
Details (original message):
Implement the backend: store/bus/runmgr core, mock and LLM runners (agent-native client), HITL three-branch routing, subagent runs, builtin and declarative HTTP tools, ingest parsers (md/txt/docx/pdf-text), knowledge fulltext, layered settings, profiles, plugin host, REST+SSE API, single-binary entrypoint; e2e and unit tests.
2026-09-29 17:53:24 +00:00

137 lines
5.3 KiB
Go

// 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/<id>
}
// 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]