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.
137 lines
5.3 KiB
Go
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]
|