Add dual-tier storage (embedded SQLite + optional PostgreSQL)
Details (original message): Add dual-tier storage (embedded SQLite + optional PostgreSQL), tenant owner scoping, API key auth, versioned dual-dialect migrations, integrity hardening, and batched event persistence
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
// agent-system:单二进制服务(HTTP/SSE + 内嵌前端 + 嵌入式 SQLite)。
|
||||
// agent-system:单二进制服务(HTTP/SSE + 内嵌前端 + 双档存储)。
|
||||
// 部署约束(设计 4.1 原则 8):非容器化;拷贝即运行。
|
||||
// 嵌入式档默认(SQLite + 本地对象目录,零外部服务);--dsn 切换 PostgreSQL 档
|
||||
//(多用户/多实例,认证默认开启,apikey 子命令管理密钥)。
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -20,6 +23,8 @@ func main() {
|
||||
return
|
||||
case "serve":
|
||||
os.Args = append([]string{os.Args[0]}, os.Args[2:]...)
|
||||
case "apikey":
|
||||
os.Exit(runAPIKey(os.Args[2:]))
|
||||
}
|
||||
}
|
||||
fs := flag.NewFlagSet("serve", flag.ExitOnError)
|
||||
@@ -27,10 +32,69 @@ func main() {
|
||||
data := fs.String("data", "./data", "数据目录(SQLite 与工件对象目录)")
|
||||
pluginsDir := fs.String("plugins", "./plugins", "插件目录(声明资产热加载)")
|
||||
mock := fs.Bool("mock", false, "演示模式(等同 runtime.mock=true,不调用模型)")
|
||||
dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式 SQLite 档)")
|
||||
artifacts := fs.String("artifacts", "", "工件对象目录(默认 <data>/artifacts;PG 档多实例时指向共享卷)")
|
||||
auth := fs.String("auth", "auto", "API key 认证:auto(PG 档开、嵌入式关)| on | off")
|
||||
fs.Parse(os.Args[1:])
|
||||
|
||||
if err := app.Run(app.Options{Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, ForceMock: *mock}); err != nil {
|
||||
if err := app.Run(app.Options{
|
||||
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, ForceMock: *mock,
|
||||
StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth,
|
||||
}); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// runAPIKey:密钥管理子命令(create/list/revoke)。
|
||||
func runAPIKey(args []string) int {
|
||||
fs := flag.NewFlagSet("apikey", flag.ExitOnError)
|
||||
data := fs.String("data", "./data", "数据目录")
|
||||
dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式档)")
|
||||
owner := fs.String("owner", "", "租户名(owner)")
|
||||
name := fs.String("name", "default", "密钥名称")
|
||||
fs.Parse(args)
|
||||
if len(fs.Args()) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "用法:agent-system apikey <create|list|revoke> [--owner x --name y]")
|
||||
return 2
|
||||
}
|
||||
if *owner == "" {
|
||||
fmt.Fprintln(os.Stderr, "apikey: --owner 必填")
|
||||
return 2
|
||||
}
|
||||
switch fs.Args()[0] {
|
||||
case "create":
|
||||
key, err := app.CreateAPIKey(*dsn, *data, *owner, *name)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "apikey create:", err)
|
||||
return 1
|
||||
}
|
||||
// 明文仅此一次输出;库中只存 SHA-256 摘要。
|
||||
fmt.Println(key)
|
||||
return 0
|
||||
case "list":
|
||||
keys, err := app.ListAPIKeys(*dsn, *data, *owner)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "apikey list:", err)
|
||||
return 1
|
||||
}
|
||||
out, _ := json.MarshalIndent(keys, "", " ")
|
||||
fmt.Println(string(out))
|
||||
return 0
|
||||
case "revoke":
|
||||
ok, err := app.RevokeAPIKey(*dsn, *data, *owner, *name)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "apikey revoke:", err)
|
||||
return 1
|
||||
}
|
||||
if !ok {
|
||||
fmt.Fprintf(os.Stderr, "apikey revoke: 未找到 owner=%s name=%s 的有效密钥\n", *owner, *name)
|
||||
return 1
|
||||
}
|
||||
fmt.Println("revoked")
|
||||
return 0
|
||||
default:
|
||||
fmt.Fprintln(os.Stderr, "apikey: 未知子命令", fs.Args()[0])
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
-1
Submodule deps/agent-system-core updated: c7ae99b13c...03ba8dd54e
@@ -15,10 +15,17 @@ require (
|
||||
error-code-system v0.0.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.6 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/crypto v0.37.0 // indirect
|
||||
golang.org/x/sync v0.23.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
logging-system v0.0.0 // indirect
|
||||
modernc.org/libc v1.77.1 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
@@ -6,22 +7,40 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0 h1:7Q+xNAZFmnfYOMweHN3c/PDFUKKfY1pVJ26K++QvVfU=
|
||||
github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
golang.org/x/tools v0.50.0 h1:c2ifzfcuY7L90lZ2aKd8S4K2NpASF08SZx9ZuJkHmSU=
|
||||
golang.org/x/tools v0.50.0/go.mod h1:7ulVMw3831Mwi5EZD6RomGyffr4VFjuNYXf2BbCEAV0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.29.7 h1:q+NXGJ0bK3b4TXFYQQVr9pYETGnmwFWkrUzJnMya/Tg=
|
||||
modernc.org/cc/v4 v4.29.7/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.36.1 h1:ZNIUZAryN0UgnJwtyxrdEzcFc3yD4Cu4AzjfPXsLsIE=
|
||||
|
||||
+81
-6
@@ -1,4 +1,5 @@
|
||||
// Package app 组合根:装配存储、总线、注册表、服务、runmgr、插件宿主与 HTTP 服务。
|
||||
// Package app 组合根:装配存储(嵌入式/PG 档)、总线、注册表、服务、runmgr、
|
||||
// 认证、插件宿主与 HTTP 服务。
|
||||
package app
|
||||
|
||||
import (
|
||||
@@ -31,16 +32,29 @@ type Options struct {
|
||||
DataDir string
|
||||
PluginsDir string
|
||||
ForceMock bool
|
||||
StorageDSN string // 空 = 嵌入式 SQLite 档;非空 = PostgreSQL 档(多用户/多实例)
|
||||
ArtifactsDir string // PG 档工件对象目录(空 = <DataDir>/artifacts)
|
||||
AuthMode string // auto(默认:PG 档开、嵌入式关)| on | off
|
||||
}
|
||||
|
||||
// Run 装配并阻塞服务直至 SIGINT/SIGTERM(优雅停机:中断在途 run)。
|
||||
// Run 装配并阻塞服务直至 SIGINT/SIGTERM(优雅停机:中断在途 run、冲净事件缓冲)。
|
||||
func Run(opt Options) error {
|
||||
dbPath := filepath.Join(opt.DataDir, "agent-system.db")
|
||||
st, err := store.Open(dbPath)
|
||||
var st *store.Store
|
||||
var err error
|
||||
if opt.StorageDSN != "" {
|
||||
artifacts := opt.ArtifactsDir
|
||||
if artifacts == "" {
|
||||
artifacts = filepath.Join(opt.DataDir, "artifacts")
|
||||
}
|
||||
st, err = store.OpenPostgres(opt.StorageDSN, artifacts)
|
||||
} else {
|
||||
st, err = store.OpenSQLite(filepath.Join(opt.DataDir, "agent-system.db"))
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("open store: %w", err)
|
||||
}
|
||||
defer st.Close()
|
||||
log.Printf("storage tier: %s", st.Dialect())
|
||||
|
||||
b := bus.New()
|
||||
reg := tools.NewRegistry()
|
||||
@@ -54,7 +68,7 @@ func Run(opt Options) error {
|
||||
return fmt.Errorf("default profile: %w", err)
|
||||
}
|
||||
if opt.ForceMock {
|
||||
if err := setSvc.MergeUser(map[string]any{"runtime": map[string]any{"mock": true}}); err != nil {
|
||||
if err := setSvc.MergeUser(store.OwnerDefault, map[string]any{"runtime": map[string]any{"mock": true}}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -77,6 +91,12 @@ func Run(opt Options) error {
|
||||
}
|
||||
|
||||
srv := server.New(st, mgr, reg, profSvc, setSvc, pluginHost)
|
||||
// 认证策略(auto):PG 档默认开启(多用户面不能裸奔);嵌入式档默认关闭(本地单用户)。
|
||||
authOn := opt.AuthMode == "on" || (opt.AuthMode != "off" && opt.StorageDSN != "")
|
||||
srv.Auth = server.NewAuthenticator(st, authOn)
|
||||
if authOn {
|
||||
log.Printf("auth: API key required (create with: agent-system apikey create --owner <name>)")
|
||||
}
|
||||
srv.Subscribe = func(conversationID string) (<-chan core.StreamEvent, func()) {
|
||||
return b.Subscribe(conversationID)
|
||||
}
|
||||
@@ -97,5 +117,60 @@ func Run(opt Options) error {
|
||||
mgr.InterruptRunning()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return httpSrv.Shutdown(ctx)
|
||||
if err := httpSrv.Shutdown(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
mgr.Shutdown() // 冲净事件缓冲后再关存储
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateAPIKey 是 apikey 子命令的实现:在指定存储档上为 owner 创建密钥(明文仅打印一次)。
|
||||
func CreateAPIKey(storageDSN, dataDir, owner, name string) (string, error) {
|
||||
var st *store.Store
|
||||
var err error
|
||||
if storageDSN != "" {
|
||||
st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
|
||||
} else {
|
||||
st, err = store.OpenSQLite(filepath.Join(dataDir, "agent-system.db"))
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer st.Close()
|
||||
return st.CreateAPIKey(owner, name)
|
||||
}
|
||||
|
||||
// ListAPIKeys 列出租户密钥(摘要视图)。
|
||||
func ListAPIKeys(storageDSN, dataDir, owner string) ([]store.APIKeyInfo, error) {
|
||||
var st *store.Store
|
||||
var err error
|
||||
if storageDSN != "" {
|
||||
st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
|
||||
} else {
|
||||
st, err = store.OpenSQLite(filepath.Join(dataDir, "agent-system.db"))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer st.Close()
|
||||
if _, err := st.GetUser(owner); err != nil {
|
||||
return nil, fmt.Errorf("owner %q 不存在(先 apikey create 创建)", owner)
|
||||
}
|
||||
return st.ListAPIKeys(owner)
|
||||
}
|
||||
|
||||
// RevokeAPIKey 撤销指定名称密钥。
|
||||
func RevokeAPIKey(storageDSN, dataDir, owner, name string) (bool, error) {
|
||||
var st *store.Store
|
||||
var err error
|
||||
if storageDSN != "" {
|
||||
st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
|
||||
} else {
|
||||
st, err = store.OpenSQLite(filepath.Join(dataDir, "agent-system.db"))
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer st.Close()
|
||||
return st.RevokeAPIKeyByName(owner, name)
|
||||
}
|
||||
|
||||
@@ -224,7 +224,7 @@ func (h *Host) bindToolExecutor(desc core.ToolDesc) (tools.Executor, error) {
|
||||
}
|
||||
lookup := func(key string) string {
|
||||
// AuthRef 走用户层设置(如 models.chat.apiKey 或自定义 secret key)。
|
||||
eff := h.settings.Effective("")
|
||||
eff := h.settings.Effective(store.OwnerDefault, "")
|
||||
return eff.GetString(key, "")
|
||||
}
|
||||
return tools.HTTPExecutor(*desc.HTTP, lookup), nil
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
func newHost(t *testing.T) (*Host, *tools.Registry, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
st, err := store.Open(filepath.Join(dir, "test.db"))
|
||||
st, err := store.OpenSQLite(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ import (
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
// OwnerDefault 与 store 保持一致的默认租户(内置画像共享,owner='')。
|
||||
const OwnerDefault = store.OwnerDefault
|
||||
|
||||
const DefaultProfileID = "default"
|
||||
|
||||
func DefaultProfile() core.AgentProfile {
|
||||
@@ -32,16 +35,16 @@ func New(st *store.Store) *Service { return &Service{st: st} }
|
||||
|
||||
// EnsureDefault 幂等写入内置画像。
|
||||
func (s *Service) EnsureDefault() error {
|
||||
if _, err := s.st.GetProfile(DefaultProfileID); err == nil {
|
||||
if _, err := s.st.GetProfile(OwnerDefault, DefaultProfileID); err == nil {
|
||||
return nil
|
||||
}
|
||||
return s.st.SaveProfile(DefaultProfile())
|
||||
}
|
||||
|
||||
func (s *Service) List() ([]core.AgentProfile, error) { return s.st.ListProfiles() }
|
||||
func (s *Service) List(owner string) ([]core.AgentProfile, error) { return s.st.ListProfiles(owner) }
|
||||
|
||||
func (s *Service) Get(id string) (core.AgentProfile, error) {
|
||||
p, err := s.st.GetProfile(id)
|
||||
func (s *Service) Get(owner, id string) (core.AgentProfile, error) {
|
||||
p, err := s.st.GetProfile(owner, id)
|
||||
if err == nil {
|
||||
return p, nil
|
||||
}
|
||||
@@ -49,17 +52,18 @@ func (s *Service) Get(id string) (core.AgentProfile, error) {
|
||||
return DefaultProfile(), nil
|
||||
}
|
||||
|
||||
func (s *Service) Save(p core.AgentProfile) error {
|
||||
func (s *Service) Save(owner string, p core.AgentProfile) error {
|
||||
if p.ID == DefaultProfileID {
|
||||
return errReadOnly("profiles: builtin profile is read-only; duplicate and modify")
|
||||
}
|
||||
if p.Tools.Allow == nil {
|
||||
p.Tools.Allow = []string{"*"}
|
||||
}
|
||||
p.Owner = owner
|
||||
return s.st.SaveProfile(p)
|
||||
}
|
||||
|
||||
func (s *Service) Delete(id string) error { return s.st.DeleteProfile(id) }
|
||||
func (s *Service) Delete(owner, id string) error { return s.st.DeleteProfile(owner, id) }
|
||||
|
||||
type errReadOnly string
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package runmgr
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
core "agent-system-core"
|
||||
"agent-system-backend/internal/bus"
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
// eventBuffer 是会话事件批处理器(写放大治理):
|
||||
// - 高频增量(message.delta / thinking.delta / agent.progress)缓冲合并落盘;
|
||||
// - 其余事件(终态、HITL、工具、阶段)立即落盘,落盘前先冲掉同会话缓冲保证顺序;
|
||||
// - seq 在落盘事务内分配(会话内连续),发布顺序 = seq 顺序;
|
||||
// - 停机清空缓冲;未冲盘的增量在崩溃时丢失(≤ interval 窗口,重放由终态消息树对账)。
|
||||
//
|
||||
// 会话内事件天然单线程(一个会话至多一个在途 run,子代理在父执行栈内同步运行),
|
||||
// 少数跨 goroutine 路径(HITL resolve)与冲盘互斥由 flushing 锁保证不交叉。
|
||||
type eventBuffer struct {
|
||||
store *store.Store
|
||||
bus *bus.Bus
|
||||
interval time.Duration // 定时冲盘窗口
|
||||
maxBuf int // 单会话缓冲条数上限(触发立即冲盘)
|
||||
|
||||
mu sync.Mutex
|
||||
buf map[string][]core.StreamEvent
|
||||
flushing sync.Mutex // 串行化落盘(批量插入很快,全局锁足够)
|
||||
|
||||
ticker *time.Ticker
|
||||
done chan struct{}
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func newEventBuffer(st *store.Store, b *bus.Bus) *eventBuffer {
|
||||
eb := &eventBuffer{
|
||||
store: st, bus: b,
|
||||
interval: 150 * time.Millisecond,
|
||||
maxBuf: 64,
|
||||
buf: map[string][]core.StreamEvent{},
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
eb.wg.Add(1)
|
||||
go eb.loop()
|
||||
return eb
|
||||
}
|
||||
|
||||
func (eb *eventBuffer) loop() {
|
||||
defer eb.wg.Done()
|
||||
eb.ticker = time.NewTicker(eb.interval)
|
||||
defer eb.ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-eb.done:
|
||||
return
|
||||
case <-eb.ticker.C:
|
||||
eb.flushAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// stop 停机:停止定时器并清空全部缓冲(优雅停机时无增量丢失)。
|
||||
func (eb *eventBuffer) stop() {
|
||||
close(eb.done)
|
||||
eb.wg.Wait()
|
||||
eb.flushAll()
|
||||
}
|
||||
|
||||
// isDelta 标记高频可缓冲事件。
|
||||
func isDelta(typ core.StreamEventType) bool {
|
||||
switch typ {
|
||||
case core.EvMessageDelta, core.EvThinkingDelta, core.EvAgentProgress:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// emit 入队一条事件;返回已落盘事件的 seq(缓冲事件返回 0)与错误。
|
||||
func (eb *eventBuffer) emit(conversationID, runID string, typ core.StreamEventType, payload any) (int64, error) {
|
||||
e := core.StreamEvent{
|
||||
ConversationID: conversationID, RunID: runID,
|
||||
TS: time.Now().UTC().Format(time.RFC3339Nano), V: core.ProtocolVersion, Type: typ,
|
||||
}
|
||||
if payload != nil {
|
||||
buf, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
e.Payload = buf
|
||||
}
|
||||
if !isDelta(typ) {
|
||||
// 立即事件:先冲掉同会话缓冲(保序),再单条落盘发布。
|
||||
eb.flushConversation(conversationID)
|
||||
batch := []core.StreamEvent{e}
|
||||
if err := eb.append(conversationID, batch); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return batch[0].Seq, nil
|
||||
}
|
||||
eb.mu.Lock()
|
||||
eb.buf[conversationID] = append(eb.buf[conversationID], e)
|
||||
n := len(eb.buf[conversationID])
|
||||
eb.mu.Unlock()
|
||||
if n >= eb.maxBuf {
|
||||
eb.flushConversation(conversationID)
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// append 落盘并按 seq 顺序发布。
|
||||
func (eb *eventBuffer) append(conversationID string, evs []core.StreamEvent) error {
|
||||
eb.flushing.Lock()
|
||||
defer eb.flushing.Unlock()
|
||||
if _, err := eb.store.AppendEvents(conversationID, evs); err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range evs {
|
||||
eb.bus.Publish(evs[i])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// flushConversation 冲掉指定会话的缓冲(失败记日志丢弃:增量可由重放补偿)。
|
||||
func (eb *eventBuffer) flushConversation(conversationID string) {
|
||||
eb.mu.Lock()
|
||||
evs := eb.buf[conversationID]
|
||||
delete(eb.buf, conversationID)
|
||||
eb.mu.Unlock()
|
||||
if len(evs) == 0 {
|
||||
return
|
||||
}
|
||||
if err := eb.append(conversationID, evs); err != nil {
|
||||
log.Printf("[events] flush %s: %v(%d 条增量待重放补偿)", conversationID, err, len(evs))
|
||||
}
|
||||
}
|
||||
|
||||
func (eb *eventBuffer) flushAll() {
|
||||
eb.mu.Lock()
|
||||
ids := make([]string, 0, len(eb.buf))
|
||||
for id := range eb.buf {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
eb.mu.Unlock()
|
||||
for _, id := range ids {
|
||||
eb.flushConversation(id)
|
||||
}
|
||||
}
|
||||
+54
-30
@@ -30,11 +30,19 @@ type Manager struct {
|
||||
mock runtime.Runner // 会话脚本回放(演示/测试与 mock 模式)
|
||||
mockSub runtime.Runner // 子代理脚本回放
|
||||
gate IntentGate // 可选意图闸门(nil = 未启用,行为回退)
|
||||
events *eventBuffer // 事件批处理(写放大治理;seq 权威仍在 store)
|
||||
|
||||
mu sync.Mutex
|
||||
active map[string]*runHandle
|
||||
|
||||
// 会话内串行(设计并发策略):一个会话同时至多一个在途 conversation run。
|
||||
convMu sync.Mutex
|
||||
convActive map[string]bool // conversationID → 有在途 run
|
||||
}
|
||||
|
||||
// ErrConversationBusy 表示会话已有在途 run(HTTP 层映射 409)。
|
||||
var ErrConversationBusy = fmt.Errorf("runmgr: conversation already has an in-flight run")
|
||||
|
||||
type runHandle struct {
|
||||
cancel context.CancelFunc
|
||||
hitlCh chan hitlAnswer
|
||||
@@ -49,9 +57,17 @@ type hitlAnswer struct {
|
||||
}
|
||||
|
||||
func New(st *store.Store, b *bus.Bus, reg *tools.Registry, p *profiles.Service, s *settings.Service, runner, subRunner, mockRunner, mockSubRunner runtime.Runner) *Manager {
|
||||
return &Manager{st: st, bus: b, registry: reg, profiles: p, settings: s, runner: runner, subRunner: subRunner, mock: mockRunner, mockSub: mockSubRunner, active: map[string]*runHandle{}}
|
||||
return &Manager{
|
||||
st: st, bus: b, registry: reg, profiles: p, settings: s,
|
||||
runner: runner, subRunner: subRunner, mock: mockRunner, mockSub: mockSubRunner,
|
||||
active: map[string]*runHandle{}, convActive: map[string]bool{},
|
||||
events: newEventBuffer(st, b),
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown 停机清空事件缓冲(在 store.Close 之前调用)。
|
||||
func (m *Manager) Shutdown() { m.events.stop() }
|
||||
|
||||
// IntentGate 是可选意图闸门端口(intentgate.Gate 实现;nil = 未启用,行为回退)。
|
||||
type IntentGate interface {
|
||||
Match(ctx context.Context, input string) (branch, topID string)
|
||||
@@ -111,7 +127,7 @@ func (c *runCtx) AskHITL(card core.HITLCard) (core.HITLResponse, error) {
|
||||
if card.CreatedAt == "" {
|
||||
card.CreatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
if err := c.mgr.st.SaveHITLCard(&card); err != nil {
|
||||
if err := c.mgr.st.SaveHITLCard(c.run.Owner, &card); err != nil {
|
||||
return core.HITLResponse{}, err
|
||||
}
|
||||
c.mgr.setRunStatus(&c.run, core.RunWaitingHITL, c.run.Phase)
|
||||
@@ -149,7 +165,7 @@ func (c *runCtx) UpdateTodos(l core.TodoList) error {
|
||||
}
|
||||
|
||||
func (c *runCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) {
|
||||
art := core.Artifact{ProjectID: c.run.ProjectID, Name: name, Kind: kind, MIME: mime, SourceRun: c.run.ID}
|
||||
art := core.Artifact{ProjectID: c.run.ProjectID, Owner: c.run.Owner, Name: name, Kind: kind, MIME: mime, SourceRun: c.run.ID}
|
||||
if err := c.mgr.st.CreateArtifact(&art, content); err != nil {
|
||||
return art, err
|
||||
}
|
||||
@@ -158,7 +174,7 @@ func (c *runCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Ar
|
||||
}
|
||||
// 工件文本进知识索引(全文检索最小实现)。
|
||||
if kind == "document" && len(content) < 200000 {
|
||||
_ = c.mgr.st.AddKnowledge(core.KnowledgeDoc{ProjectID: c.run.ProjectID, Source: "artifact", Title: name, Ref: art.ID, Chunk: string(content)})
|
||||
_ = c.mgr.st.AddKnowledge(core.KnowledgeDoc{Owner: c.run.Owner, ProjectID: c.run.ProjectID, Source: "artifact", Title: name, Ref: art.ID, Chunk: string(content)})
|
||||
}
|
||||
return art, nil
|
||||
}
|
||||
@@ -170,7 +186,7 @@ func (c *runCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) {
|
||||
if q.ProjectID == "" {
|
||||
q.ProjectID = c.run.ProjectID
|
||||
}
|
||||
return c.mgr.st.SearchKnowledge(q)
|
||||
return c.mgr.st.SearchKnowledge(c.run.Owner, q)
|
||||
}
|
||||
|
||||
// ExecuteTool 执行画像策略:deny→错误;confirm→HITL confirm 卡片;执行→截断转工件。
|
||||
@@ -233,13 +249,23 @@ func (t toolCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Ar
|
||||
// StartConversationRun 为一条已落库的用户消息启动 conversation run。
|
||||
// attachments 为已解析的附件文档(server 层经 ingest 处理)。
|
||||
func (m *Manager) StartConversationRun(conv core.Conversation, userMessage core.Message, attachments []core.ParsedDocument) (core.Run, error) {
|
||||
prof, _ := m.profiles.Get(conv.ProfileID)
|
||||
eff := m.settings.Effective(conv.ProjectID)
|
||||
// 会话内串行(设计策略落地):在途 run(含 HITL 挂起)未终态前拒绝新 run。
|
||||
m.convMu.Lock()
|
||||
if m.convActive[conv.ID] {
|
||||
m.convMu.Unlock()
|
||||
return core.Run{}, ErrConversationBusy
|
||||
}
|
||||
m.convActive[conv.ID] = true
|
||||
m.convMu.Unlock()
|
||||
|
||||
prof, _ := m.profiles.Get(conv.Owner, conv.ProfileID)
|
||||
eff := m.settings.Effective(conv.Owner, conv.ProjectID)
|
||||
run := core.Run{
|
||||
ID: store.NewID("run"), ConversationID: conv.ID, ProjectID: conv.ProjectID,
|
||||
ID: store.NewID("run"), ConversationID: conv.ID, ProjectID: conv.ProjectID, Owner: conv.Owner,
|
||||
Kind: core.RunConversation, ProfileID: prof.ID, Status: core.RunPending, Phase: core.PhaseThinking,
|
||||
}
|
||||
if err := m.st.CreateRun(&run); err != nil {
|
||||
m.releaseConversation(conv.ID)
|
||||
return run, err
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@@ -257,9 +283,17 @@ func (m *Manager) StartConversationRun(conv core.Conversation, userMessage core.
|
||||
return run, nil
|
||||
}
|
||||
|
||||
// releaseConversation 释放会话在途槽位(run 终态/启动失败时)。
|
||||
func (m *Manager) releaseConversation(conversationID string) {
|
||||
m.convMu.Lock()
|
||||
delete(m.convActive, conversationID)
|
||||
m.convMu.Unlock()
|
||||
}
|
||||
|
||||
// execute 运行至终态并收敛:状态、usage、助手消息、收尾事件。
|
||||
func (m *Manager) execute(rc *runCtx, runner runtime.Runner) {
|
||||
defer func() {
|
||||
m.releaseConversation(rc.run.ConversationID)
|
||||
m.mu.Lock()
|
||||
delete(m.active, rc.run.ID)
|
||||
m.mu.Unlock()
|
||||
@@ -335,24 +369,14 @@ func (m *Manager) setRunStatus(r *core.Run, status core.RunStatus, phase core.Ru
|
||||
m.st.UpdateRun(r)
|
||||
}
|
||||
|
||||
// emit 持久化事件并广播;返回 seq。
|
||||
// emit 经批处理器持久化事件并广播;返回 seq(缓冲中的增量返回 0)。
|
||||
func (m *Manager) emit(conversationID, runID string, typ core.StreamEventType, payload any) (int64, error) {
|
||||
e := core.StreamEvent{
|
||||
ConversationID: conversationID, RunID: runID,
|
||||
TS: time.Now().UTC().Format(time.RFC3339Nano), V: core.ProtocolVersion, Type: typ,
|
||||
}
|
||||
seq, err := m.st.AppendEvent(&e, payload)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
e.Seq = seq
|
||||
m.bus.Publish(e)
|
||||
return seq, nil
|
||||
return m.events.emit(conversationID, runID, typ, payload)
|
||||
}
|
||||
|
||||
// CancelRun 取消 run 及其子 run(ctx 级联)。
|
||||
func (m *Manager) CancelRun(runID string) error {
|
||||
r, err := m.st.GetRun(runID)
|
||||
r, err := m.st.GetRunByID(runID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -390,15 +414,15 @@ func (m *Manager) CancelRun(runID string) error {
|
||||
// 1) 选项命中 → 回填挂起 run;
|
||||
// 2) 自由输入以 "/" 开头(命令)→ 取消挂起 run,返回转轨标志由 server 层发送新消息;
|
||||
// 3) 其他自由输入 → 作为澄清回答回填。
|
||||
func (m *Manager) ResolveHITL(cardID string, resp core.HITLResponse) (pivotText string, pivoted bool, err error) {
|
||||
card, err := m.st.GetHITLCard(cardID)
|
||||
func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (pivotText string, pivoted bool, err error) {
|
||||
card, err := m.st.GetHITLCard(owner, cardID)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
if card.Resolved {
|
||||
return "", false, fmt.Errorf("runmgr: card %s already resolved", cardID)
|
||||
}
|
||||
run, err := m.st.GetRun(card.RunID)
|
||||
run, err := m.st.GetRunByID(card.RunID)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
@@ -428,14 +452,14 @@ func (m *Manager) ResolveHITL(cardID string, resp core.HITLResponse) (pivotText
|
||||
}
|
||||
if pivot {
|
||||
_ = m.CancelRun(run.ID)
|
||||
m.st.ResolveHITLCard(cardID, core.HITLResolvedPayload{CardID: core.ID(cardID), Kind: "cancelled", Answer: answer})
|
||||
m.st.ResolveHITLCard(owner, cardID, core.HITLResolvedPayload{CardID: core.ID(cardID), Kind: "cancelled", Answer: answer})
|
||||
m.emit(run.ConversationID, run.ID, core.EvHITLResolved, core.HITLResolvedPayload{CardID: core.ID(cardID), Kind: "cancelled", Answer: answer})
|
||||
_ = pivotReason
|
||||
return trimmed, true, nil
|
||||
}
|
||||
}
|
||||
resolution := core.HITLResolvedPayload{CardID: core.ID(cardID), Answer: answer, Kind: kind}
|
||||
if err := m.st.ResolveHITLCard(cardID, resolution); err != nil {
|
||||
if err := m.st.ResolveHITLCard(owner, cardID, resolution); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
m.emit(run.ConversationID, run.ID, core.EvHITLResolved, resolution)
|
||||
@@ -464,7 +488,7 @@ func (m *Manager) ResolveHITL(cardID string, resp core.HITLResponse) (pivotText
|
||||
func (m *Manager) spawnSubagent(parentCtx context.Context, parent core.Run, name, task, hint string) (core.AgentResultPayload, error) {
|
||||
child := core.Run{
|
||||
ID: store.NewID("run"), ParentRunID: parent.ID, ConversationID: parent.ConversationID,
|
||||
ProjectID: parent.ProjectID, Kind: core.RunSubagent, ProfileID: parent.ProfileID,
|
||||
ProjectID: parent.ProjectID, Owner: parent.Owner, Kind: core.RunSubagent, ProfileID: parent.ProfileID,
|
||||
Status: core.RunPending, Phase: core.PhaseThinking, AgentName: name,
|
||||
}
|
||||
if err := m.st.CreateRun(&child); err != nil {
|
||||
@@ -478,8 +502,8 @@ func (m *Manager) spawnSubagent(parentCtx context.Context, parent core.Run, name
|
||||
m.active[child.ID] = h
|
||||
m.mu.Unlock()
|
||||
|
||||
prof, _ := m.profiles.Get(parent.ProfileID)
|
||||
eff := m.settings.Effective(parent.ProjectID)
|
||||
prof, _ := m.profiles.Get(parent.Owner, parent.ProfileID)
|
||||
eff := m.settings.Effective(parent.Owner, parent.ProjectID)
|
||||
rc := &runCtx{mgr: m, run: child, prof: prof, eff: eff, ctx: ctx, h: h, subTask: task, subHint: hint}
|
||||
m.setRunStatus(&rc.run, core.RunRunning, core.PhaseActing)
|
||||
|
||||
@@ -561,7 +585,7 @@ func (m *Manager) InterruptRunning() {
|
||||
h.cancel()
|
||||
}
|
||||
for _, id := range ids {
|
||||
r, err := m.st.GetRun(id)
|
||||
r, err := m.st.GetRunByID(id)
|
||||
if err != nil || r.Status == core.RunCompleted || r.Status == core.RunFailed || r.Status == core.RunCancelled {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
// ownerCtxKey 携带租户身份(认证中间件注入;未启用认证 = store.OwnerDefault)。
|
||||
type ownerCtxKey struct{}
|
||||
|
||||
// Authenticator 是 API key 认证(阶段 1:user 即租户)。
|
||||
// Enabled=false(嵌入式档默认,回环本地单用户)时全部请求回落默认租户。
|
||||
// SSE 的 EventSource 无法带 Authorization 头,允许 ?key= 查询参数兜底。
|
||||
type Authenticator struct {
|
||||
st *store.Store
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
func NewAuthenticator(st *store.Store, enabled bool) *Authenticator {
|
||||
return &Authenticator{st: st, Enabled: enabled}
|
||||
}
|
||||
|
||||
func (a *Authenticator) Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if a == nil || !a.Enabled {
|
||||
next.ServeHTTP(w, r.WithContext(withOwner(r, store.OwnerDefault)))
|
||||
return
|
||||
}
|
||||
key := bearerKey(r)
|
||||
if key == "" {
|
||||
key = r.URL.Query().Get("key")
|
||||
}
|
||||
if key == "" {
|
||||
http.Error(w, "unauthorized: missing API key (Authorization: Bearer <key>)", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
owner, ok := a.st.AuthOwner(key)
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized: invalid or revoked API key", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(withOwner(r, owner)))
|
||||
})
|
||||
}
|
||||
|
||||
func withOwner(r *http.Request, owner string) context.Context {
|
||||
return context.WithValue(r.Context(), ownerCtxKey{}, owner)
|
||||
}
|
||||
|
||||
func bearerKey(r *http.Request) string {
|
||||
h := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(h, "Bearer ") {
|
||||
return strings.TrimSpace(strings.TrimPrefix(h, "Bearer "))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// owner 从请求上下文取租户身份(未注入回落默认)。
|
||||
func (s *Server) owner(r *http.Request) string {
|
||||
if v, ok := r.Context().Value(ownerCtxKey{}).(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return store.OwnerDefault
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
package server
|
||||
|
||||
// 认证与多租户 e2e(阶段 1):API key 中间件、租户隔离(404 负例)、
|
||||
// 会话在途互斥(409)、备份端点、SSE 越权拒绝。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/bus"
|
||||
"agent-system-backend/internal/plugins"
|
||||
"agent-system-backend/internal/profiles"
|
||||
"agent-system-backend/internal/runmgr"
|
||||
"agent-system-backend/internal/runtime"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/internal/tools"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// newAuthTestServer:开启 API key 认证的组合根(测试版)。
|
||||
func newAuthTestServer(t *testing.T) (*Server, *store.Store) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
st, err := store.OpenSQLite(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
|
||||
b := bus.New()
|
||||
reg := tools.NewRegistry()
|
||||
reg.RegisterBuiltin()
|
||||
setSvc := settings.NewService(st)
|
||||
profSvc := profiles.New(st)
|
||||
profSvc.EnsureDefault()
|
||||
mgr := runmgr.New(st, b, reg, profSvc, setSvc, &runtime.LLMRunner{}, &runtime.SubLLMRunner{}, &runtime.MockRunner{}, &runtime.MockSubagent{})
|
||||
ph := plugins.New(st, reg, setSvc, filepath.Join(dir, "plugins"))
|
||||
srv := New(st, mgr, reg, profSvc, setSvc, ph)
|
||||
srv.Auth = NewAuthenticator(st, true)
|
||||
srv.Subscribe = func(cid string) (<-chan core.StreamEvent, func()) { return b.Subscribe(cid) }
|
||||
return srv, st
|
||||
}
|
||||
|
||||
func TestAuthMiddleware(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
|
||||
// 未带 key:401。
|
||||
req := httptest.NewRequest("GET", "/api/projects", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("no key: %d", rec.Code)
|
||||
}
|
||||
|
||||
// 创建密钥。
|
||||
key, err := st.CreateAPIKey("alice", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Bearer 头:200。
|
||||
req = httptest.NewRequest("GET", "/api/projects", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("bearer: %d", rec.Code)
|
||||
}
|
||||
|
||||
// ?key= 查询参数(EventSource 路径):200。
|
||||
req = httptest.NewRequest("GET", "/api/projects?key="+key, nil)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("query key: %d", rec.Code)
|
||||
}
|
||||
|
||||
// 撤销后 401。
|
||||
if ok, err := st.RevokeAPIKeyByName("alice", "e2e"); err != nil || !ok {
|
||||
t.Fatalf("revoke: %v %v", ok, err)
|
||||
}
|
||||
req = httptest.NewRequest("GET", "/api/projects", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("revoked: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// 租户隔离(HTTP 负例):alice 的资源对 bob 一律 404。
|
||||
func TestTenantIsolationHTTP(t *testing.T) {
|
||||
srv, st := newAuthTestServer(t)
|
||||
h := srv.Handler()
|
||||
keyA, _ := st.CreateAPIKey("alice", "a")
|
||||
keyB, _ := st.CreateAPIKey("bob", "b")
|
||||
|
||||
do := func(key, method, path string, body any) int {
|
||||
var buf bytes.Buffer
|
||||
if body != nil {
|
||||
json.NewEncoder(&buf).Encode(body)
|
||||
}
|
||||
req := httptest.NewRequest(method, path, &buf)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec.Code
|
||||
}
|
||||
|
||||
if c := do(keyA, "POST", "/api/projects", map[string]any{"name": "A"}); c != 200 {
|
||||
t.Fatalf("alice create: %d", c)
|
||||
}
|
||||
var projects []map[string]any
|
||||
req := httptest.NewRequest("GET", "/api/projects", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+keyA)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
json.Unmarshal(rec.Body.Bytes(), &projects)
|
||||
if len(projects) != 1 {
|
||||
t.Fatalf("alice projects = %v", projects)
|
||||
}
|
||||
projectID := projects[0]["id"].(string)
|
||||
|
||||
// bob 看不到 alice 的项目/会话/工件。
|
||||
if c := do(keyB, "PATCH", "/api/projects/"+projectID, map[string]any{"name": "劫持"}); c != 404 {
|
||||
t.Fatalf("bob patch alice project: %d", c)
|
||||
}
|
||||
if c := do(keyB, "GET", "/api/nonexistent", nil); c != 404 {
|
||||
t.Fatalf("unknown api path must 404, got %d", c)
|
||||
}
|
||||
if c := do(keyB, "GET", "/api/projects/"+projectID+"/conversations", nil); c != 404 {
|
||||
t.Fatalf("bob list alice conversations: %d", c)
|
||||
}
|
||||
if c := do(keyB, "DELETE", "/api/projects/"+projectID, nil); c != 404 {
|
||||
t.Fatalf("bob delete alice project: %d", c)
|
||||
}
|
||||
// bob 项目列表为空(不是 404,是空数组)。
|
||||
req = httptest.NewRequest("GET", "/api/projects", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+keyB)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
var bobProjects []map[string]any
|
||||
json.Unmarshal(rec.Body.Bytes(), &bobProjects)
|
||||
if len(bobProjects) != 0 {
|
||||
t.Fatalf("bob projects = %v", bobProjects)
|
||||
}
|
||||
// bob 不能在 alice 项目下建会话。
|
||||
if c := do(keyB, "POST", "/api/projects/"+projectID+"/conversations", map[string]any{"title": "x"}); c != 404 {
|
||||
t.Fatalf("bob create conversation in alice project: %d", c)
|
||||
}
|
||||
// 设置按租户隔离。
|
||||
if c := do(keyA, "PUT", "/api/settings/user", map[string]any{"ui": map[string]any{"theme": "light"}}); c != 200 {
|
||||
t.Fatalf("alice settings: %d", c)
|
||||
}
|
||||
req = httptest.NewRequest("GET", "/api/settings", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+keyB)
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
var s map[string]any
|
||||
json.Unmarshal(rec.Body.Bytes(), &s)
|
||||
if u := s["user"].(map[string]any); len(u) != 0 {
|
||||
t.Fatalf("bob user settings must be empty, got %v", u)
|
||||
}
|
||||
}
|
||||
|
||||
// 会话在途互斥:mock run 进行中(阻塞 runner)第二个消息 409。
|
||||
func TestConversationBusyConflict(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, err := store.OpenSQLite(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
|
||||
b := bus.New()
|
||||
reg := tools.NewRegistry()
|
||||
reg.RegisterBuiltin()
|
||||
setSvc := settings.NewService(st)
|
||||
profSvc := profiles.New(st)
|
||||
profSvc.EnsureDefault()
|
||||
|
||||
block := make(chan struct{}, 1) // 缓冲:通知不阻塞执行
|
||||
release := make(chan struct{})
|
||||
// 阻塞 runner:第一个 run 挂住直到 release。
|
||||
blocking := blockingRunner{block: block, release: release}
|
||||
mgr := runmgr.New(st, b, reg, profSvc, setSvc, blocking, blocking, &runtime.MockRunner{}, &runtime.MockSubagent{})
|
||||
ph := plugins.New(st, reg, setSvc, filepath.Join(dir, "plugins"))
|
||||
srv := New(st, mgr, reg, profSvc, setSvc, ph)
|
||||
h := srv.Handler()
|
||||
|
||||
// 建项目与会话(认证关闭,默认租户)。
|
||||
_, out := doJSON(t, h, "POST", "/api/projects", map[string]any{"name": "p"})
|
||||
projectID := out["id"].(string)
|
||||
_, out = doJSON(t, h, "POST", "/api/projects/"+projectID+"/conversations", map[string]any{"title": "c"})
|
||||
convID := out["id"].(string)
|
||||
|
||||
msg := core.Message{ConversationID: convID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "hi"}}}
|
||||
conv, _ := st.GetConversation(store.OwnerDefault, convID)
|
||||
if _, err := mgr.StartConversationRun(conv, msg, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 第二个 run:409。
|
||||
if _, err := mgr.StartConversationRun(conv, msg, nil); err != runmgr.ErrConversationBusy {
|
||||
t.Fatalf("second run must be busy, got %v", err)
|
||||
}
|
||||
// 释放第一个 run,槽位归还后可再启动。
|
||||
close(release)
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
_, err := mgr.StartConversationRun(conv, msg, nil)
|
||||
if err == nil {
|
||||
return // 槽位已归还:通过
|
||||
}
|
||||
if err != runmgr.ErrConversationBusy {
|
||||
t.Fatalf("unexpected: %v", err)
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("conversation slot not released after run finished")
|
||||
}
|
||||
|
||||
type blockingRunner struct {
|
||||
block chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (r blockingRunner) Name() string { return "blocking" }
|
||||
func (r blockingRunner) Execute(rc runtime.RunCtx) error {
|
||||
rc.SetAssistantParts([]core.Part{{Type: core.PartText, Text: "done"}})
|
||||
r.block <- struct{}{} // 通知已到位
|
||||
<-r.release // 等待释放
|
||||
return nil
|
||||
}
|
||||
|
||||
// 备份端点:产出一致性副本文件。
|
||||
func TestBackupEndpoint(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
h := srv.Handler()
|
||||
code, out := doJSON(t, h, "POST", "/api/admin/backup", nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("backup: %d %v", code, out)
|
||||
}
|
||||
path := out["path"].(string)
|
||||
if !strings.HasSuffix(path, ".db") {
|
||||
t.Fatalf("backup path: %s", path)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("backup file: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -31,12 +31,12 @@ import (
|
||||
func newTestServer(t *testing.T) (*Server, *store.Store) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
st, err := store.Open(filepath.Join(dir, "test.db"))
|
||||
st, err := store.OpenSQLite(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
_ = st.MergeSettingsScope(settings.ScopeUser, map[string]any{"runtime": map[string]any{"mock": true}})
|
||||
_ = st.MergeSettingsScope(store.OwnerDefault, settings.ScopeUser, map[string]any{"runtime": map[string]any{"mock": true}})
|
||||
|
||||
b := bus.New()
|
||||
reg := tools.NewRegistry()
|
||||
@@ -620,13 +620,13 @@ func TestModelRoutingOverrideChain(t *testing.T) {
|
||||
|
||||
// user 层:model-A。
|
||||
set(map[string]any{"models": map[string]any{"chat": map[string]any{"model": "model-A"}}}, "/api/settings/user")
|
||||
eff := settings.NewService(st).Effective(projectID)
|
||||
eff := settings.NewService(st).Effective(store.OwnerDefault, projectID)
|
||||
if got := eff.GetString("models.chat.model", ""); got != "model-A" {
|
||||
t.Fatalf("user layer: %q", got)
|
||||
}
|
||||
// project 层:model-B(覆盖 user)。
|
||||
set(map[string]any{"models": map[string]any{"chat": map[string]any{"model": "model-B"}}}, "/api/settings/project/"+projectID)
|
||||
eff = settings.NewService(st).Effective(projectID)
|
||||
eff = settings.NewService(st).Effective(store.OwnerDefault, projectID)
|
||||
if got := eff.GetString("models.chat.model", ""); got != "model-B" {
|
||||
t.Fatalf("project layer should win: %q", got)
|
||||
}
|
||||
|
||||
+105
-44
@@ -9,6 +9,8 @@ import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -26,6 +28,7 @@ import (
|
||||
|
||||
type Server struct {
|
||||
st *store.Store
|
||||
Auth *Authenticator // API key 认证(nil/未启用 = 默认租户;组合根注入)
|
||||
mgr *runmgr.Manager
|
||||
reg *tools.Registry
|
||||
prof *profiles.Service
|
||||
@@ -102,9 +105,19 @@ func (s *Server) Handler() http.Handler {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "ts": time.Now().UTC()})
|
||||
})
|
||||
|
||||
// 静态前端(embed)。
|
||||
// ---- 管理(嵌入式档在线备份)----
|
||||
mux.HandleFunc("POST /api/admin/backup", s.backup)
|
||||
|
||||
// API 未匹配路径一律 404(不回落 SPA,避免 /api/* 返回 HTML 200)。
|
||||
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })
|
||||
|
||||
// 静态前端(embed);API 全链路经认证中间件(未启用 = 默认租户)。
|
||||
var h http.Handler = mux
|
||||
if s.Auth != nil {
|
||||
h = s.Auth.Middleware(mux)
|
||||
}
|
||||
mux.HandleFunc("/", s.serveStatic)
|
||||
return mux
|
||||
return h
|
||||
}
|
||||
|
||||
// ---- SSE:游标重放 + 在线订阅 + 终态对账由 Query 承担 ----
|
||||
@@ -125,6 +138,11 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
|
||||
// 租户边界:跨租户会话不可订阅(NotFound,不泄露存在性)。
|
||||
if _, err := s.st.GetConversation(s.owner(r), conversationID); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
since := int64(0)
|
||||
if ids := r.Header.Get("Last-Event-ID"); ids != "" {
|
||||
since, _ = strconv.ParseInt(ids, 10, 64)
|
||||
@@ -213,7 +231,7 @@ func (s *Server) serveStatic(w http.ResponseWriter, r *http.Request) {
|
||||
// ---- handlers:项目与会话 ----
|
||||
|
||||
func (s *Server) listProjects(w http.ResponseWriter, r *http.Request) {
|
||||
ps, err := s.st.ListProjects()
|
||||
ps, err := s.st.ListProjects(s.owner(r))
|
||||
writeJSONOr(w, ps, err)
|
||||
}
|
||||
|
||||
@@ -223,7 +241,7 @@ func (s *Server) createProject(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
p, err := s.st.CreateProject(strings.TrimSpace(req.Name))
|
||||
p, err := s.st.CreateProject(s.owner(r), strings.TrimSpace(req.Name))
|
||||
writeJSONOr(w, p, err)
|
||||
}
|
||||
|
||||
@@ -238,27 +256,32 @@ func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if req.Name != nil {
|
||||
if err := s.st.RenameProject(id, *req.Name); err != nil {
|
||||
if err := s.st.RenameProject(s.owner(r), id, *req.Name); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if req.Settings != nil {
|
||||
if err := s.st.UpdateProjectSettings(id, req.Settings); err != nil {
|
||||
if err := s.st.UpdateProjectSettings(s.owner(r), id, req.Settings); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
p, err := s.st.GetProject(id)
|
||||
p, err := s.st.GetProject(s.owner(r), id)
|
||||
writeJSONOr(w, p, err)
|
||||
}
|
||||
|
||||
func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request) {
|
||||
writeOKOr(w, s.st.DeleteProject(r.PathValue("id")))
|
||||
writeOKOr(w, s.st.DeleteProject(s.owner(r), r.PathValue("id")))
|
||||
}
|
||||
|
||||
func (s *Server) listConversations(w http.ResponseWriter, r *http.Request) {
|
||||
cs, err := s.st.ListConversations(r.PathValue("id"))
|
||||
owner := s.owner(r)
|
||||
if _, err := s.st.GetProject(owner, r.PathValue("id")); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
cs, err := s.st.ListConversations(owner, r.PathValue("id"))
|
||||
writeJSONOr(w, cs, err)
|
||||
}
|
||||
|
||||
@@ -271,21 +294,22 @@ func (s *Server) createConversation(w http.ResponseWriter, r *http.Request) {
|
||||
if req.Title == "" {
|
||||
req.Title = "新会话"
|
||||
}
|
||||
c, err := s.st.CreateConversation(r.PathValue("id"), req.Title, req.ProfileID)
|
||||
c, err := s.st.CreateConversation(s.owner(r), r.PathValue("id"), req.Title, req.ProfileID)
|
||||
writeJSONOr(w, c, err)
|
||||
}
|
||||
|
||||
// conversationDetail 终态对账权威:消息树 + runs + 活动 HITL 卡片。
|
||||
func (s *Server) conversationDetail(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
conv, err := s.st.GetConversation(id)
|
||||
owner := s.owner(r)
|
||||
conv, err := s.st.GetConversation(owner, id)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
msgs, _ := s.st.ListMessages(id)
|
||||
runs, _ := s.st.ListRunsByConversation(id)
|
||||
cards, _ := s.st.ActiveHITLCards(id)
|
||||
cards, _ := s.st.ActiveHITLCards(owner, id)
|
||||
lastSeq, _ := s.st.LastEventSeq(id)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"conversation": conv, "messages": msgs, "runs": runs,
|
||||
@@ -300,18 +324,19 @@ func (s *Server) patchConversation(w http.ResponseWriter, r *http.Request) {
|
||||
ProfileID string `json:"profileId"`
|
||||
}
|
||||
json.NewDecoder(r.Body).Decode(&req)
|
||||
writeOKOr(w, s.st.UpdateConversation(id, req.Title, req.ProfileID))
|
||||
writeOKOr(w, s.st.UpdateConversation(s.owner(r), id, req.Title, req.ProfileID))
|
||||
}
|
||||
|
||||
func (s *Server) deleteConversation(w http.ResponseWriter, r *http.Request) {
|
||||
writeOKOr(w, s.st.DeleteConversation(r.PathValue("id")))
|
||||
writeOKOr(w, s.st.DeleteConversation(s.owner(r), r.PathValue("id")))
|
||||
}
|
||||
|
||||
// ---- 消息发送(含命令路由与附件) ----
|
||||
|
||||
func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
|
||||
convID := r.PathValue("id")
|
||||
conv, err := s.st.GetConversation(convID)
|
||||
owner := s.owner(r)
|
||||
conv, err := s.st.GetConversation(owner, convID)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
@@ -342,12 +367,12 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
var parsedDocs []core.ParsedDocument
|
||||
for _, artID := range req.AttachmentIDs {
|
||||
art, err := s.st.GetArtifact(artID)
|
||||
art, err := s.st.GetArtifact(owner, artID)
|
||||
if err != nil {
|
||||
http.Error(w, "attachment "+artID+" not found", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
content, _ := s.st.ArtifactContent(artID)
|
||||
content, _ := s.st.ArtifactContent(owner, artID)
|
||||
doc := ingest.Parse(art.Name, art.MIME, content)
|
||||
parsedDocs = append(parsedDocs, doc)
|
||||
parts = append(parts, core.Part{Type: core.PartArtifactRef, Artifact: &core.ArtifactRef{ArtifactID: art.ID, Name: art.Name, Kind: art.Kind}})
|
||||
@@ -369,7 +394,7 @@ func (s *Server) handleCommand(w http.ResponseWriter, r *http.Request, conv core
|
||||
case "/help":
|
||||
writeJSON(w, http.StatusOK, map[string]any{"command": cmd, "reply": "可用命令:/new 新会话 · /mock on|off 演示模式 · /model <名称> 设置主模型 · /profile <id> 切换画像 · /help 帮助"})
|
||||
case "/new":
|
||||
c, err := s.st.CreateConversation(conv.ProjectID, "新会话", conv.ProfileID)
|
||||
c, err := s.st.CreateConversation(conv.Owner, conv.ProjectID, "新会话", conv.ProfileID)
|
||||
writeJSONOr(w, map[string]any{"command": cmd, "conversation": c}, err)
|
||||
case "/mock":
|
||||
if len(fields) < 2 || (fields[1] != "on" && fields[1] != "off") {
|
||||
@@ -377,19 +402,19 @@ func (s *Server) handleCommand(w http.ResponseWriter, r *http.Request, conv core
|
||||
return
|
||||
}
|
||||
val := fields[1] == "on"
|
||||
writeJSONOr(w, map[string]any{"command": cmd, "runtime.mock": val}, s.set.MergeUser(map[string]any{"runtime": map[string]any{"mock": val}}))
|
||||
writeJSONOr(w, map[string]any{"command": cmd, "runtime.mock": val}, s.set.MergeUser(conv.Owner, map[string]any{"runtime": map[string]any{"mock": val}}))
|
||||
case "/model":
|
||||
if len(fields) < 2 {
|
||||
http.Error(w, "usage: /model <model-name>", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeJSONOr(w, map[string]any{"command": cmd, "model": fields[1]}, s.set.MergeUser(map[string]any{"models": map[string]any{"chat": map[string]any{"model": fields[1]}}}))
|
||||
writeJSONOr(w, map[string]any{"command": cmd, "model": fields[1]}, s.set.MergeUser(conv.Owner, map[string]any{"models": map[string]any{"chat": map[string]any{"model": fields[1]}}}))
|
||||
case "/profile":
|
||||
if len(fields) < 2 {
|
||||
http.Error(w, "usage: /profile <id>", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeJSONOr(w, map[string]any{"command": cmd, "profile": fields[1]}, s.st.UpdateConversation(conv.ID, "", fields[1]))
|
||||
writeJSONOr(w, map[string]any{"command": cmd, "profile": fields[1]}, s.st.UpdateConversation(conv.Owner, conv.ID, "", fields[1]))
|
||||
default:
|
||||
http.Error(w, "未知命令 "+cmd+"(/help 查看)", http.StatusBadRequest)
|
||||
}
|
||||
@@ -400,7 +425,7 @@ func (s *Server) cancelRun(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) getRun(w http.ResponseWriter, r *http.Request) {
|
||||
run, err := s.st.GetRun(r.PathValue("id"))
|
||||
run, err := s.st.GetRun(s.owner(r), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
@@ -418,7 +443,7 @@ func (s *Server) resolveHITL(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
resp.CardID = r.PathValue("id")
|
||||
pivotText, pivoted, err := s.mgr.ResolveHITL(string(resp.CardID), resp)
|
||||
pivotText, pivoted, err := s.mgr.ResolveHITL(s.owner(r), string(resp.CardID), resp)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
@@ -442,8 +467,13 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
owner := s.owner(r)
|
||||
if _, err := s.st.GetProject(owner, projectID); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
mime := header.Header.Get("Content-Type")
|
||||
art := core.Artifact{ProjectID: projectID, Name: header.Filename, Kind: kindOf(header.Filename), MIME: mime}
|
||||
art := core.Artifact{ProjectID: projectID, Owner: owner, Name: header.Filename, Kind: kindOf(header.Filename), MIME: mime}
|
||||
if err := s.st.CreateArtifact(&art, content); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
@@ -451,7 +481,7 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
|
||||
doc := ingest.Parse(art.Name, mime, content)
|
||||
// 文本可解析的进知识索引(全文检索)。
|
||||
if plain := doc.PlainText(); plain != "" && len(plain) < 200000 {
|
||||
_ = s.st.AddKnowledge(core.KnowledgeDoc{ProjectID: projectID, Source: "upload", Title: art.Name, Ref: art.ID, Chunk: plain})
|
||||
_ = s.st.AddKnowledge(core.KnowledgeDoc{Owner: owner, ProjectID: projectID, Source: "upload", Title: art.Name, Ref: art.ID, Chunk: plain})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, core.UploadInfo{ArtifactID: art.ID, Filename: art.Name, MIME: mime, Size: art.Size, Parsed: &doc})
|
||||
}
|
||||
@@ -468,22 +498,27 @@ func kindOf(filename string) string {
|
||||
}
|
||||
|
||||
func (s *Server) listArtifacts(w http.ResponseWriter, r *http.Request) {
|
||||
as, err := s.st.ListArtifacts(r.PathValue("id"))
|
||||
owner := s.owner(r)
|
||||
if _, err := s.st.GetProject(owner, r.PathValue("id")); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
as, err := s.st.ListArtifacts(owner, r.PathValue("id"))
|
||||
writeJSONOr(w, as, err)
|
||||
}
|
||||
|
||||
func (s *Server) getArtifact(w http.ResponseWriter, r *http.Request) {
|
||||
a, err := s.st.GetArtifact(r.PathValue("id"))
|
||||
a, err := s.st.GetArtifact(s.owner(r), r.PathValue("id"))
|
||||
writeJSONOr(w, a, err)
|
||||
}
|
||||
|
||||
func (s *Server) artifactContent(w http.ResponseWriter, r *http.Request) {
|
||||
a, err := s.st.GetArtifact(r.PathValue("id"))
|
||||
a, err := s.st.GetArtifact(s.owner(r), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
content, err := s.st.ArtifactContent(a.ID)
|
||||
content, err := s.st.ArtifactContent(s.owner(r), a.ID)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
@@ -494,17 +529,17 @@ func (s *Server) artifactContent(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (s *Server) putArtifactContent(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(io.LimitReader(r.Body, 20<<20))
|
||||
writeOKOr(w, s.st.UpdateArtifactContent(r.PathValue("id"), body))
|
||||
writeOKOr(w, s.st.UpdateArtifactContent(s.owner(r), r.PathValue("id"), body))
|
||||
}
|
||||
|
||||
func (s *Server) deleteArtifact(w http.ResponseWriter, r *http.Request) {
|
||||
writeOKOr(w, s.st.DeleteArtifact(r.PathValue("id")))
|
||||
writeOKOr(w, s.st.DeleteArtifact(s.owner(r), r.PathValue("id")))
|
||||
}
|
||||
|
||||
// ---- 画像 ----
|
||||
|
||||
func (s *Server) listProfiles(w http.ResponseWriter, r *http.Request) {
|
||||
ps, err := s.prof.List()
|
||||
ps, err := s.prof.List(s.owner(r))
|
||||
writeJSONOr(w, ps, err)
|
||||
}
|
||||
|
||||
@@ -517,7 +552,7 @@ func (s *Server) saveProfile(w http.ResponseWriter, r *http.Request) {
|
||||
if p.ID == "" {
|
||||
p.ID = "prof_" + strconv.FormatInt(time.Now().UnixNano()%100000, 10)
|
||||
}
|
||||
if err := s.prof.Save(p); err != nil {
|
||||
if err := s.prof.Save(s.owner(r), p); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -525,35 +560,36 @@ func (s *Server) saveProfile(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) deleteProfile(w http.ResponseWriter, r *http.Request) {
|
||||
writeOKOr(w, s.prof.Delete(r.PathValue("id")))
|
||||
writeOKOr(w, s.prof.Delete(s.owner(r), r.PathValue("id")))
|
||||
}
|
||||
|
||||
// ---- 设置 ----
|
||||
|
||||
func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
|
||||
owner := s.owner(r)
|
||||
projectID := r.URL.Query().Get("project")
|
||||
schema := append(settings.Schema(), s.plugins.SettingsSchema()...)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"schema": schema,
|
||||
"defaults": settings.Masked(core.EffectiveSettings(settings.SystemDefaults())),
|
||||
"user": maskMap(mustUser(s.set)),
|
||||
"project": maskMap(mustScope(s.set, projectID)),
|
||||
"user": maskMap(mustUser(s.set, owner)),
|
||||
"project": maskMap(mustScope(s.set, owner, projectID)),
|
||||
})
|
||||
}
|
||||
|
||||
func mustUser(s *settings.Service) map[string]any {
|
||||
m, _ := s.UserScope()
|
||||
func mustUser(s *settings.Service, owner string) map[string]any {
|
||||
m, _ := s.UserScope(owner)
|
||||
if m == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func mustScope(s *settings.Service, projectID string) map[string]any {
|
||||
func mustScope(s *settings.Service, owner, projectID string) map[string]any {
|
||||
if projectID == "" {
|
||||
return map[string]any{}
|
||||
}
|
||||
m, _ := s.ProjectScope(projectID)
|
||||
m, _ := s.ProjectScope(owner, projectID)
|
||||
return m
|
||||
}
|
||||
|
||||
@@ -563,7 +599,7 @@ func (s *Server) putSettingsUser(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeOKOr(w, s.set.MergeUser(values))
|
||||
writeOKOr(w, s.set.MergeUser(s.owner(r), values))
|
||||
}
|
||||
|
||||
func (s *Server) putSettingsProject(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -572,7 +608,7 @@ func (s *Server) putSettingsProject(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeOKOr(w, s.set.MergeProject(r.PathValue("id"), values))
|
||||
writeOKOr(w, s.set.MergeProject(s.owner(r), r.PathValue("id"), values))
|
||||
}
|
||||
|
||||
// ---- 工具(用户声明式 HTTP 工具注册)----
|
||||
@@ -592,7 +628,7 @@ func (s *Server) createUserTool(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "v1 用户工具需为声明式 HTTP 工具(提供 http 段)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
lookup := func(key string) string { return s.set.Effective("").GetString(key, "") }
|
||||
lookup := func(key string) string { return s.set.Effective(s.owner(r), "").GetString(key, "") }
|
||||
if err := s.reg.Register(desc, tools.HTTPExecutor(*desc.HTTP, lookup)); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
@@ -702,7 +738,13 @@ func (s *Server) addKnowledge(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
owner := s.owner(r)
|
||||
if _, err := s.st.GetProject(owner, r.PathValue("id")); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
for i := range docs {
|
||||
docs[i].Owner = owner
|
||||
docs[i].ProjectID = r.PathValue("id")
|
||||
if docs[i].Source == "" {
|
||||
docs[i].Source = "manual"
|
||||
@@ -718,7 +760,7 @@ func (s *Server) searchKnowledge(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
topK, _ := strconv.Atoi(r.URL.Query().Get("topK"))
|
||||
hits, err := s.st.SearchKnowledge(core.RetrievalQuery{ProjectID: r.URL.Query().Get("project"), Text: q, TopK: topK})
|
||||
hits, err := s.st.SearchKnowledge(s.owner(r), core.RetrievalQuery{ProjectID: r.URL.Query().Get("project"), Text: q, TopK: topK})
|
||||
writeJSONOr(w, hits, err)
|
||||
}
|
||||
|
||||
@@ -751,9 +793,28 @@ func writeStoreErr(w http.ResponseWriter, err error) {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, runmgr.ErrConversationBusy) {
|
||||
http.Error(w, err.Error(), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// backup 触发一致性在线备份(嵌入式档:VACUUM INTO;PG 档见运维指南 pg_dump)。
|
||||
func (s *Server) backup(w http.ResponseWriter, r *http.Request) {
|
||||
dir := filepath.Join(s.st.DataDir(), "backups")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
dest := filepath.Join(dir, "agent-system-"+time.Now().UTC().Format("20060102T150405Z")+".db")
|
||||
if err := s.st.Backup(dest); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotImplemented)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "path": dest})
|
||||
}
|
||||
|
||||
func contentTypeOf(a core.Artifact) string {
|
||||
if a.MIME != "" {
|
||||
return a.MIME
|
||||
|
||||
@@ -79,33 +79,38 @@ func NewService(st *store.Store) *Service { return &Service{st: st} }
|
||||
// SetSystemOverride 注入启动期系统层覆盖(组合根调用;不落库)。
|
||||
func (s *Service) SetSystemOverride(values map[string]any) { s.system = values }
|
||||
|
||||
// Effective 计算覆盖链结果:defaults → system(启动注入) → user → project。
|
||||
func (s *Service) Effective(projectID string) core.EffectiveSettings {
|
||||
// Effective 计算覆盖链结果:defaults → system(启动注入) → user(租户) → project。
|
||||
// owner 是租户身份(认证中间件注入;未启用认证 = store.OwnerDefault)。
|
||||
func (s *Service) Effective(owner, projectID string) core.EffectiveSettings {
|
||||
eff := core.MergeSettings(SystemDefaults(), map[string]any{})
|
||||
if s.system != nil {
|
||||
eff = core.MergeSettings(eff, s.system)
|
||||
}
|
||||
if user, err := s.st.GetSettingsScope(ScopeUser); err == nil {
|
||||
if user, err := s.st.GetSettingsScope(owner, ScopeUser); err == nil {
|
||||
eff = core.MergeSettings(eff, user)
|
||||
}
|
||||
if projectID != "" {
|
||||
if proj, err := s.st.GetSettingsScope(ScopeProjectPrefix + projectID); err == nil {
|
||||
if proj, err := s.st.GetSettingsScope(owner, ScopeProjectPrefix+projectID); err == nil {
|
||||
eff = core.MergeSettings(eff, proj)
|
||||
}
|
||||
}
|
||||
return eff
|
||||
}
|
||||
|
||||
func (s *Service) UserScope() (map[string]any, error) { return s.st.GetSettingsScope(ScopeUser) }
|
||||
|
||||
func (s *Service) ProjectScope(projectID string) (map[string]any, error) {
|
||||
return s.st.GetSettingsScope(ScopeProjectPrefix + projectID)
|
||||
func (s *Service) UserScope(owner string) (map[string]any, error) {
|
||||
return s.st.GetSettingsScope(owner, ScopeUser)
|
||||
}
|
||||
|
||||
func (s *Service) MergeUser(values map[string]any) error { return s.st.MergeSettingsScope(ScopeUser, values) }
|
||||
func (s *Service) ProjectScope(owner, projectID string) (map[string]any, error) {
|
||||
return s.st.GetSettingsScope(owner, ScopeProjectPrefix+projectID)
|
||||
}
|
||||
|
||||
func (s *Service) MergeProject(projectID string, values map[string]any) error {
|
||||
return s.st.MergeSettingsScope(ScopeProjectPrefix+projectID, values)
|
||||
func (s *Service) MergeUser(owner string, values map[string]any) error {
|
||||
return s.st.MergeSettingsScope(owner, ScopeUser, values)
|
||||
}
|
||||
|
||||
func (s *Service) MergeProject(owner, projectID string, values map[string]any) error {
|
||||
return s.st.MergeSettingsScope(owner, ScopeProjectPrefix+projectID, values)
|
||||
}
|
||||
|
||||
// Masked 返回脱敏视图(secret 类 key 掩码),用于 API 响应。Effective 每次
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---- 认证与租户(阶段 1:API key 认证,user 即租户 owner)----
|
||||
// key 明文仅创建时返回一次;库中只存 SHA-256 摘要(数据可靠性标准:不落明文凭证)。
|
||||
|
||||
// User 是租户账户(owner 全局唯一;id 供后续扩展用户目录)。
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Owner string `json:"owner"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// APIKeyInfo 是密钥的展示视图(不含摘要)。
|
||||
type APIKeyInfo struct {
|
||||
Name string `json:"name"`
|
||||
Owner string `json:"owner"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
|
||||
Revoked bool `json:"revoked"`
|
||||
}
|
||||
|
||||
func hashKey(plain string) string {
|
||||
sum := sha256.Sum256([]byte(plain))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// EnsureUser 幂等创建租户账户(owner 唯一)。
|
||||
func (s *Store) EnsureUser(owner, name string) error {
|
||||
_, err := s.db.Exec(s.q(`INSERT INTO users(id,owner,name,created_at) VALUES(?,?,?,?)
|
||||
ON CONFLICT(owner) DO NOTHING`), newID("usr"), owner, name, nowISO())
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetUser(owner string) (User, error) {
|
||||
var u User
|
||||
var ca string
|
||||
err := s.db.QueryRow(s.q(`SELECT id,owner,name,created_at FROM users WHERE owner=?`), owner).Scan(&u.ID, &u.Owner, &u.Name, &ca)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return u, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return u, err
|
||||
}
|
||||
u.CreatedAt, _ = parseTime(ca)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListUsers() ([]User, error) {
|
||||
rows, err := s.db.Query(s.q(`SELECT id,owner,name,created_at FROM users ORDER BY created_at`))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []User
|
||||
for rows.Next() {
|
||||
var u User
|
||||
var ca string
|
||||
if err := rows.Scan(&u.ID, &u.Owner, &u.Name, &ca); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.CreatedAt, _ = parseTime(ca)
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CreateAPIKey 创建密钥:明文 `ask_<hex32>` 仅此一次返回。
|
||||
func (s *Store) CreateAPIKey(owner, name string) (string, error) {
|
||||
if err := s.EnsureUser(owner, ""); err != nil {
|
||||
return "", err
|
||||
}
|
||||
raw := make([]byte, 16)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", err
|
||||
}
|
||||
plain := "ask_" + hex.EncodeToString(raw)
|
||||
if name == "" {
|
||||
name = "key-" + nowISO()[:10]
|
||||
}
|
||||
_, err := s.db.Exec(s.q(`INSERT INTO api_keys(key_hash,owner,name,created_at,revoked) VALUES(?,?,?,?,0)`),
|
||||
hashKey(plain), owner, name, nowISO())
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return plain, nil
|
||||
}
|
||||
|
||||
// RevokeAPIKeyByName 撤销指定名称的未撤销密钥(返回是否命中)。
|
||||
func (s *Store) RevokeAPIKeyByName(owner, name string) (bool, error) {
|
||||
res, err := s.db.Exec(s.q(`UPDATE api_keys SET revoked=1 WHERE owner=? AND name=? AND revoked=0`), owner, name)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListAPIKeys(owner string) ([]APIKeyInfo, error) {
|
||||
rows, err := s.db.Query(s.q(`SELECT name,owner,created_at,last_used_at,revoked FROM api_keys WHERE owner=? ORDER BY created_at`), owner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []APIKeyInfo
|
||||
for rows.Next() {
|
||||
var k APIKeyInfo
|
||||
var ca string
|
||||
var last sql.NullString
|
||||
if err := rows.Scan(&k.Name, &k.Owner, &ca, &last, &k.Revoked); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k.CreatedAt, _ = parseTime(ca)
|
||||
if last.Valid && last.String != "" {
|
||||
t, _ := parseTime(last.String)
|
||||
k.LastUsedAt = &t
|
||||
}
|
||||
out = append(out, k)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// AuthOwner 校验 API key:命中返回 owner;未命中/已撤销返回 false。
|
||||
// last_used_at 尽力刷新(失败不影响认证结果)。
|
||||
func (s *Store) AuthOwner(plainKey string) (string, bool) {
|
||||
if plainKey == "" {
|
||||
return "", false
|
||||
}
|
||||
var owner string
|
||||
var revoked int
|
||||
err := s.db.QueryRow(s.q(`SELECT owner,revoked FROM api_keys WHERE key_hash=?`), hashKey(plainKey)).Scan(&owner, &revoked)
|
||||
if err != nil || revoked == 1 {
|
||||
return "", false
|
||||
}
|
||||
s.db.Exec(s.q(`UPDATE api_keys SET last_used_at=? WHERE key_hash=?`), nowISO(), hashKey(plainKey))
|
||||
return owner, true
|
||||
}
|
||||
+50
-25
@@ -7,16 +7,18 @@ import (
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// ---- 画像 ----
|
||||
// ---- 画像(builtin=owner'' 共享;用户画像按 owner 隔离)----
|
||||
|
||||
func (s *Store) SaveProfile(p core.AgentProfile) error {
|
||||
_, err := s.db.Exec(`INSERT OR REPLACE INTO profiles(id,builtin,payload) VALUES(?,?,?)`,
|
||||
p.ID, boolInt(p.Builtin), marshalJSON(p))
|
||||
_, err := s.db.Exec(s.q(`INSERT INTO profiles(id,owner,builtin,payload) VALUES(?,?,?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET owner=excluded.owner, builtin=excluded.builtin, payload=excluded.payload`),
|
||||
p.ID, p.Owner, boolInt(p.Builtin), marshalJSON(p))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ListProfiles() ([]core.AgentProfile, error) {
|
||||
rows, err := s.db.Query(`SELECT payload FROM profiles ORDER BY builtin DESC, id`)
|
||||
// ListProfiles 返回该租户的画像 + 共享内置画像(owner='')。
|
||||
func (s *Store) ListProfiles(owner string) ([]core.AgentProfile, error) {
|
||||
rows, err := s.db.Query(s.q(`SELECT payload FROM profiles WHERE owner=? OR builtin=1 ORDER BY builtin DESC, id`), owner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -33,9 +35,9 @@ func (s *Store) ListProfiles() ([]core.AgentProfile, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetProfile(id string) (core.AgentProfile, error) {
|
||||
func (s *Store) GetProfile(owner, id string) (core.AgentProfile, error) {
|
||||
var payload string
|
||||
err := s.db.QueryRow(`SELECT payload FROM profiles WHERE id=?`, id).Scan(&payload)
|
||||
err := s.db.QueryRow(s.q(`SELECT payload FROM profiles WHERE id=? AND (owner=? OR builtin=1)`), id, owner).Scan(&payload)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return core.AgentProfile{}, ErrNotFound
|
||||
}
|
||||
@@ -45,23 +47,27 @@ func (s *Store) GetProfile(id string) (core.AgentProfile, error) {
|
||||
return unmarshalJSON[core.AgentProfile](payload)
|
||||
}
|
||||
|
||||
func (s *Store) DeleteProfile(id string) error {
|
||||
func (s *Store) DeleteProfile(owner, id string) error {
|
||||
var builtin int
|
||||
if err := s.db.QueryRow(`SELECT builtin FROM profiles WHERE id=?`, id).Scan(&builtin); err != nil {
|
||||
err := s.db.QueryRow(s.q(`SELECT builtin FROM profiles WHERE id=? AND owner=?`), id, owner).Scan(&builtin)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if builtin == 1 {
|
||||
return errors.New("store: builtin profile cannot be deleted")
|
||||
}
|
||||
_, err := s.db.Exec(`DELETE FROM profiles WHERE id=?`, id)
|
||||
_, err = s.db.Exec(s.q(`DELETE FROM profiles WHERE id=? AND owner=?`), id, owner)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- 设置(scope = user | project:<id>)----
|
||||
// ---- 设置(scope = user | project:<id>;PK=(owner,scope),租户隔离)----
|
||||
|
||||
func (s *Store) GetSettingsScope(scope string) (map[string]any, error) {
|
||||
func (s *Store) GetSettingsScope(owner, scope string) (map[string]any, error) {
|
||||
var payload string
|
||||
err := s.db.QueryRow(`SELECT "values" FROM settings WHERE scope=?`, scope).Scan(&payload)
|
||||
err := s.db.QueryRow(s.q(`SELECT "values" FROM settings WHERE owner=? AND scope=?`), owner, scope).Scan(&payload)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
@@ -78,31 +84,50 @@ func (s *Store) GetSettingsScope(scope string) (map[string]any, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) MergeSettingsScope(scope string, values map[string]any) error {
|
||||
cur, err := s.GetSettingsScope(scope)
|
||||
// MergeSettingsScope 读改写走事务 + scope 锁:并发合并不再丢失更新。
|
||||
func (s *Store) MergeSettingsScope(owner, scope string, values map[string]any) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err := s.dialect.lockScope(tx, owner, scope); err != nil {
|
||||
return err
|
||||
}
|
||||
var payload string
|
||||
err = tx.QueryRow(s.q(`SELECT "values" FROM settings WHERE owner=? AND scope=?`), owner, scope).Scan(&payload)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
cur := map[string]any{}
|
||||
if payload != "" {
|
||||
cur, _ = unmarshalJSON[map[string]any](payload)
|
||||
}
|
||||
merged := core.MergeSettings(cur, values)
|
||||
_, err = s.db.Exec(`INSERT OR REPLACE INTO settings(scope,"values") VALUES(?,?)`, scope, marshalJSON(merged))
|
||||
_, err = tx.Exec(s.q(`INSERT INTO settings(owner,scope,"values") VALUES(?,?,?)
|
||||
ON CONFLICT(owner,scope) DO UPDATE SET "values"=excluded."values"`), owner, scope, marshalJSON(merged))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSettingsScope(owner, scope string) error {
|
||||
_, err := s.db.Exec(s.q(`DELETE FROM settings WHERE owner=? AND scope=?`), owner, scope)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteSettingsScope(scope string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM settings WHERE scope=?`, scope)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- 插件注册表(加载状态;能力注册在 pluginhost 内存态)----
|
||||
// ---- 插件注册表(节点级:无租户维度;加载状态在 pluginhost 内存态)----
|
||||
|
||||
func (s *Store) UpsertPlugin(id, dir string, m core.PluginManifest) error {
|
||||
_, err := s.db.Exec(`INSERT OR REPLACE INTO plugins(id,dir,manifest,enabled,loaded_at) VALUES(?,?,?,?,?)`,
|
||||
_, err := s.db.Exec(s.q(`INSERT INTO plugins(id,dir,manifest,enabled,loaded_at) VALUES(?,?,?,?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET dir=excluded.dir, manifest=excluded.manifest, enabled=excluded.enabled, loaded_at=excluded.loaded_at`),
|
||||
id, dir, marshalJSON(m), 1, nowISO())
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ListPlugins() ([]core.PluginManifest, error) {
|
||||
rows, err := s.db.Query(`SELECT manifest FROM plugins ORDER BY id`)
|
||||
rows, err := s.db.Query(s.q(`SELECT manifest FROM plugins ORDER BY id`))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -120,6 +145,6 @@ func (s *Store) ListPlugins() ([]core.PluginManifest, error) {
|
||||
}
|
||||
|
||||
func (s *Store) RemovePlugin(id string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM plugins WHERE id=?`, id)
|
||||
_, err := s.db.Exec(s.q(`DELETE FROM plugins WHERE id=?`), id)
|
||||
return err
|
||||
}
|
||||
|
||||
+165
-75
@@ -3,6 +3,7 @@ package store
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
@@ -11,6 +12,20 @@ import (
|
||||
)
|
||||
|
||||
// ---- 工件(元数据在库,内容在对象目录 data/artifacts/<id>)----
|
||||
// 文件写走 临时文件 → rename → 落库:崩溃残留只可能产生无主孤立文件,
|
||||
// 不产生库里有行、对象缺失的悬空引用。
|
||||
|
||||
func (s *Store) writeBlobAtomic(path string, content []byte) error {
|
||||
tmp := path + ".tmp-" + newID("x")
|
||||
if err := os.WriteFile(tmp, content, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateArtifact(a *core.Artifact, content []byte) error {
|
||||
if a.ID == "" {
|
||||
@@ -20,60 +35,68 @@ func (s *Store) CreateArtifact(a *core.Artifact, content []byte) error {
|
||||
a.CreatedAt, a.UpdatedAt = now, now
|
||||
a.Size = int64(len(content))
|
||||
path := filepath.Join(s.dataDir, a.ID)
|
||||
if err := os.WriteFile(path, content, 0o644); err != nil {
|
||||
if err := s.writeBlobAtomic(path, content); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.db.Exec(`INSERT INTO artifacts(id,project_id,name,kind,mime,size,source_run,content_path,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)`,
|
||||
a.ID, a.ProjectID, a.Name, a.Kind, a.MIME, a.Size, a.SourceRun, path, nowISO(), nowISO())
|
||||
return err
|
||||
_, err := s.db.Exec(s.q(`INSERT INTO artifacts(id,project_id,owner,name,kind,mime,size,source_run,content_path,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)`),
|
||||
a.ID, a.ProjectID, a.Owner, a.Name, a.Kind, a.MIME, a.Size, a.SourceRun, path, nowISO(), nowISO())
|
||||
if err != nil {
|
||||
os.Remove(path) // 落库失败:回收对象,不留悬空
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateArtifactContent(id string, content []byte) error {
|
||||
func (s *Store) UpdateArtifactContent(owner, id string, content []byte) error {
|
||||
var path string
|
||||
if err := s.db.QueryRow(`SELECT content_path FROM artifacts WHERE id=?`, id).Scan(&path); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
err := s.db.QueryRow(s.q(`SELECT content_path FROM artifacts WHERE id=? AND owner=?`), id, owner).Scan(&path)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(path, content, 0o644); err != nil {
|
||||
if err := s.writeBlobAtomic(path, content); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.db.Exec(`UPDATE artifacts SET size=?, updated_at=? WHERE id=?`, len(content), nowISO(), id)
|
||||
return err
|
||||
res, err := s.db.Exec(s.q(`UPDATE artifacts SET size=?, updated_at=? WHERE id=? AND owner=?`), len(content), nowISO(), id, owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const artifactColumns = `id,project_id,name,kind,mime,size,source_run,created_at,updated_at`
|
||||
const artifactColumns = `id,project_id,owner,name,kind,mime,size,source_run,created_at,updated_at`
|
||||
|
||||
func scanArtifact(scan func(...any) error) (core.Artifact, error) {
|
||||
var a core.Artifact
|
||||
var mime, sourceRun string
|
||||
var owner, mime, sourceRun string
|
||||
var ca, ua string
|
||||
if err := scan(&a.ID, &a.ProjectID, &a.Name, &a.Kind, &mime, &a.Size, &sourceRun, &ca, &ua); err != nil {
|
||||
if err := scan(&a.ID, &a.ProjectID, &owner, &a.Name, &a.Kind, &mime, &a.Size, &sourceRun, &ca, &ua); err != nil {
|
||||
return a, err
|
||||
}
|
||||
a.MIME, a.SourceRun = mime, sourceRun
|
||||
a.Owner, a.MIME, a.SourceRun = owner, mime, sourceRun
|
||||
a.CreatedAt, _ = parseTime(ca)
|
||||
a.UpdatedAt, _ = parseTime(ua)
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetArtifact(id string) (core.Artifact, error) {
|
||||
func (s *Store) GetArtifact(owner, id string) (core.Artifact, error) {
|
||||
a, err := scanArtifact(func(dst ...any) error {
|
||||
return s.db.QueryRow(`SELECT `+artifactColumns+` FROM artifacts WHERE id=?`, id).Scan(dst...)
|
||||
return s.db.QueryRow(s.q(`SELECT `+artifactColumns+` FROM artifacts WHERE id=? AND owner=?`), id, owner).Scan(dst...)
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return a, ErrNotFound
|
||||
}
|
||||
return a, err
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return a, ErrNotFound
|
||||
}
|
||||
return a, nil
|
||||
return a, err
|
||||
}
|
||||
|
||||
func (s *Store) ArtifactContent(id string) ([]byte, error) {
|
||||
func (s *Store) ArtifactContent(owner, id string) ([]byte, error) {
|
||||
var path string
|
||||
err := s.db.QueryRow(`SELECT content_path FROM artifacts WHERE id=?`, id).Scan(&path)
|
||||
err := s.db.QueryRow(s.q(`SELECT content_path FROM artifacts WHERE id=? AND owner=?`), id, owner).Scan(&path)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
@@ -83,8 +106,8 @@ func (s *Store) ArtifactContent(id string) ([]byte, error) {
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
|
||||
func (s *Store) ListArtifacts(projectID string) ([]core.Artifact, error) {
|
||||
rows, err := s.db.Query(`SELECT `+artifactColumns+` FROM artifacts WHERE project_id=? ORDER BY updated_at DESC`, projectID)
|
||||
func (s *Store) ListArtifacts(owner, projectID string) ([]core.Artifact, error) {
|
||||
rows, err := s.db.Query(s.q(`SELECT `+artifactColumns+` FROM artifacts WHERE project_id=? AND owner=? ORDER BY updated_at DESC`), projectID, owner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -100,28 +123,37 @@ func (s *Store) ListArtifacts(projectID string) ([]core.Artifact, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) DeleteArtifact(id string) error {
|
||||
// DeleteArtifact 先删行后删对象:崩溃间隙只可能残留无主文件(可 GC),不悬空引用。
|
||||
func (s *Store) DeleteArtifact(owner, id string) error {
|
||||
var path string
|
||||
if err := s.db.QueryRow(`SELECT content_path FROM artifacts WHERE id=?`, id).Scan(&path); err != nil {
|
||||
err := s.db.QueryRow(s.q(`SELECT content_path FROM artifacts WHERE id=? AND owner=?`), id, owner).Scan(&path)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.Exec(`DELETE FROM artifacts WHERE id=?`, id); err != nil {
|
||||
if _, err := s.db.Exec(s.q(`DELETE FROM artifacts WHERE id=? AND owner=?`), id, owner); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Remove(path)
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- HITL 卡片 ----
|
||||
// ---- HITL 卡片(resolve 原子化:条件更新消灭先查后改窗口)----
|
||||
|
||||
func (s *Store) SaveHITLCard(c *core.HITLCard) error {
|
||||
_, err := s.db.Exec(`INSERT OR REPLACE INTO hitl_cards(id,run_id,conversation_id,payload,created_at,resolved,resolution) VALUES(?,?,?,?,?,?,?)`,
|
||||
c.ID, c.RunID, c.ConversationID, marshalJSON(c), nowISO(), boolInt(c.Resolved), "")
|
||||
func (s *Store) SaveHITLCard(owner string, c *core.HITLCard) error {
|
||||
_, err := s.db.Exec(s.q(`INSERT INTO hitl_cards(id,owner,run_id,conversation_id,payload,created_at,resolved,resolution) VALUES(?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET payload=excluded.payload, resolved=excluded.resolved, resolution=excluded.resolution`),
|
||||
c.ID, owner, c.RunID, c.ConversationID, marshalJSON(c), nowISO(), boolInt(c.Resolved), "")
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetHITLCard(id string) (core.HITLCard, error) {
|
||||
func (s *Store) GetHITLCard(owner, id string) (core.HITLCard, error) {
|
||||
var payload string
|
||||
err := s.db.QueryRow(`SELECT payload FROM hitl_cards WHERE id=?`, id).Scan(&payload)
|
||||
err := s.db.QueryRow(s.q(`SELECT payload FROM hitl_cards WHERE id=? AND owner=?`), id, owner).Scan(&payload)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return core.HITLCard{}, ErrNotFound
|
||||
}
|
||||
@@ -131,19 +163,29 @@ func (s *Store) GetHITLCard(id string) (core.HITLCard, error) {
|
||||
return unmarshalJSON[core.HITLCard](payload)
|
||||
}
|
||||
|
||||
func (s *Store) ResolveHITLCard(id string, resolution core.HITLResolvedPayload) error {
|
||||
c, err := s.GetHITLCard(id)
|
||||
// ResolveHITLCard 原子置为已解决;已解决的重复请求返回明确错误(先查后改竞态修复)。
|
||||
func (s *Store) ResolveHITLCard(owner, id string, resolution core.HITLResolvedPayload) error {
|
||||
c, err := s.GetHITLCard(owner, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.Resolved {
|
||||
return fmt.Errorf("store: card %s already resolved", id)
|
||||
}
|
||||
c.Resolved = true
|
||||
_, err = s.db.Exec(`UPDATE hitl_cards SET resolved=1, payload=?, resolution=? WHERE id=?`,
|
||||
marshalJSON(c), marshalJSON(resolution), id)
|
||||
return err
|
||||
res, err := s.db.Exec(s.q(`UPDATE hitl_cards SET resolved=1, payload=?, resolution=? WHERE id=? AND owner=? AND resolved=0`),
|
||||
marshalJSON(c), marshalJSON(resolution), id, owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return fmt.Errorf("store: card %s already resolved", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ActiveHITLCards(conversationID string) ([]core.HITLCard, error) {
|
||||
rows, err := s.db.Query(`SELECT payload FROM hitl_cards WHERE conversation_id=? AND resolved=0 ORDER BY created_at`, conversationID)
|
||||
func (s *Store) ActiveHITLCards(owner, conversationID string) ([]core.HITLCard, error) {
|
||||
rows, err := s.db.Query(s.q(`SELECT payload FROM hitl_cards WHERE conversation_id=? AND owner=? AND resolved=0 ORDER BY created_at`), conversationID, owner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -167,16 +209,17 @@ func boolInt(b bool) int {
|
||||
return 0
|
||||
}
|
||||
|
||||
// ---- 计划 / 待办 ----
|
||||
// ---- 计划 / 待办(会话域;归属由上游 GetConversation 校验)----
|
||||
|
||||
func (s *Store) SavePlan(conversationID string, p core.PlanSnapshot) error {
|
||||
_, err := s.db.Exec(`INSERT OR REPLACE INTO plans(conversation_id,payload) VALUES(?,?)`, conversationID, marshalJSON(p))
|
||||
_, err := s.db.Exec(s.q(`INSERT INTO plans(conversation_id,payload) VALUES(?,?)
|
||||
ON CONFLICT(conversation_id) DO UPDATE SET payload=excluded.payload`), conversationID, marshalJSON(p))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetPlan(conversationID string) (core.PlanSnapshot, error) {
|
||||
var payload string
|
||||
err := s.db.QueryRow(`SELECT payload FROM plans WHERE conversation_id=?`, conversationID).Scan(&payload)
|
||||
err := s.db.QueryRow(s.q(`SELECT payload FROM plans WHERE conversation_id=?`), conversationID).Scan(&payload)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return core.PlanSnapshot{}, ErrNotFound
|
||||
}
|
||||
@@ -187,13 +230,14 @@ func (s *Store) GetPlan(conversationID string) (core.PlanSnapshot, error) {
|
||||
}
|
||||
|
||||
func (s *Store) SaveTodos(conversationID string, l core.TodoList) error {
|
||||
_, err := s.db.Exec(`INSERT OR REPLACE INTO todos(conversation_id,payload) VALUES(?,?)`, conversationID, marshalJSON(l))
|
||||
_, err := s.db.Exec(s.q(`INSERT INTO todos(conversation_id,payload) VALUES(?,?)
|
||||
ON CONFLICT(conversation_id) DO UPDATE SET payload=excluded.payload`), conversationID, marshalJSON(l))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetTodos(conversationID string) (core.TodoList, error) {
|
||||
var payload string
|
||||
err := s.db.QueryRow(`SELECT payload FROM todos WHERE conversation_id=?`, conversationID).Scan(&payload)
|
||||
err := s.db.QueryRow(s.q(`SELECT payload FROM todos WHERE conversation_id=?`), conversationID).Scan(&payload)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return core.TodoList{}, ErrNotFound
|
||||
}
|
||||
@@ -203,50 +247,67 @@ func (s *Store) GetTodos(conversationID string) (core.TodoList, error) {
|
||||
return unmarshalJSON[core.TodoList](payload)
|
||||
}
|
||||
|
||||
// ---- 知识(全文检索最小实现:FTS5)----
|
||||
// ---- 知识(全文检索:SQLite FTS5 / PG tsvector+trigram;owner='' 为共享知识)----
|
||||
|
||||
// AddKnowledge 单事务批量写入:中途失败整体回滚,不再产生半批导入。
|
||||
func (s *Store) AddKnowledge(docs ...core.KnowledgeDoc) error {
|
||||
if len(docs) == 0 {
|
||||
return nil
|
||||
}
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
ts := nowISO()
|
||||
for _, d := range docs {
|
||||
if d.ID == "" {
|
||||
d.ID = newID("knw")
|
||||
}
|
||||
if _, err := s.db.Exec(`INSERT INTO knowledge(id,project_id,source,title,ref,chunk,created_at) VALUES(?,?,?,?,?,?,?)`,
|
||||
d.ID, d.ProjectID, d.Source, d.Title, d.Ref, d.Chunk, nowISO()); err != nil {
|
||||
if _, err := tx.Exec(s.q(`INSERT INTO knowledge(id,owner,project_id,source,title,ref,chunk,created_at) VALUES(?,?,?,?,?,?,?,?)`),
|
||||
d.ID, d.Owner, d.ProjectID, d.Source, d.Title, d.Ref, d.Chunk, ts); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) SearchKnowledge(q core.RetrievalQuery) ([]core.RetrievalHit, error) {
|
||||
// DeleteKnowledgeByRef 删除引用对应的知识(工件删除时联动清索引)。
|
||||
func (s *Store) DeleteKnowledgeByRef(owner, ref string) error {
|
||||
_, err := s.db.Exec(s.q(`DELETE FROM knowledge WHERE ref=? AND owner=?`), ref, owner)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) SearchKnowledge(owner string, q core.RetrievalQuery) ([]core.RetrievalHit, error) {
|
||||
if s.dialect.name() == "postgres" {
|
||||
return s.searchKnowledgePG(owner, q)
|
||||
}
|
||||
return s.searchKnowledgeSQLite(owner, q)
|
||||
}
|
||||
|
||||
func (s *Store) searchKnowledgeSQLite(owner string, q core.RetrievalQuery) ([]core.RetrievalHit, error) {
|
||||
topK := q.TopK
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
rows, err := s.db.Query(`
|
||||
SELECT k.id,k.project_id,k.source,k.title,k.ref,k.chunk,k.created_at, rank
|
||||
rows, err := s.db.Query(s.q(`
|
||||
SELECT k.id,k.owner,k.project_id,k.source,k.title,k.ref,k.chunk,k.created_at, rank
|
||||
FROM knowledge_fts f JOIN knowledge k ON k.rowid=f.rowid
|
||||
WHERE knowledge_fts MATCH ? ORDER BY rank LIMIT ?`, ftsQuery(q.Text), topK*2)
|
||||
WHERE knowledge_fts MATCH ? ORDER BY rank LIMIT ?`), ftsQuery(q.Text), topK*2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []core.RetrievalHit
|
||||
for rows.Next() {
|
||||
var d core.KnowledgeDoc
|
||||
var score float64
|
||||
var ca string
|
||||
if err := rows.Scan(&d.ID, &d.ProjectID, &d.Source, &d.Title, &d.Ref, &d.Chunk, &ca, &score); err != nil {
|
||||
h, err := scanKnowledgeHit(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.CreatedAt, _ = parseTime(ca)
|
||||
if q.ProjectID != "" && d.ProjectID != "" && d.ProjectID != q.ProjectID {
|
||||
if !knowledgeVisible(owner, q, h.Doc) {
|
||||
continue
|
||||
}
|
||||
if len(q.Sources) > 0 && !containsStr(q.Sources, d.Source) {
|
||||
continue
|
||||
}
|
||||
out = append(out, core.RetrievalHit{Doc: d, Score: -score, Kind: "fulltext"})
|
||||
out = append(out, h)
|
||||
if len(out) >= topK {
|
||||
break
|
||||
}
|
||||
@@ -256,14 +317,14 @@ func (s *Store) SearchKnowledge(q core.RetrievalQuery) ([]core.RetrievalHit, err
|
||||
}
|
||||
// CJK 兜底:unicode61 分词把中文整句当单 token,FTS 前缀匹配失效时用 LIKE 子串。
|
||||
if len(out) == 0 && q.Text != "" {
|
||||
return s.searchKnowledgeLike(q, topK)
|
||||
return s.searchKnowledgeLike(owner, q, topK)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *Store) searchKnowledgeLike(q core.RetrievalQuery, topK int) ([]core.RetrievalHit, error) {
|
||||
rows, err := s.db.Query(`SELECT id,project_id,source,title,ref,chunk,created_at FROM knowledge WHERE chunk LIKE ? OR title LIKE ? LIMIT ?`,
|
||||
"%"+q.Text+"%", "%"+q.Text+"%", topK)
|
||||
func (s *Store) searchKnowledgeLike(owner string, q core.RetrievalQuery, topK int) ([]core.RetrievalHit, error) {
|
||||
rows, err := s.db.Query(s.q(`SELECT id,owner,project_id,source,title,ref,chunk,created_at FROM knowledge WHERE (owner=? OR owner='') AND (chunk LIKE ? OR title LIKE ?) LIMIT ?`),
|
||||
owner, "%"+q.Text+"%", "%"+q.Text+"%", topK)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -271,12 +332,13 @@ func (s *Store) searchKnowledgeLike(q core.RetrievalQuery, topK int) ([]core.Ret
|
||||
var out []core.RetrievalHit
|
||||
for rows.Next() {
|
||||
var d core.KnowledgeDoc
|
||||
var ca string
|
||||
if err := rows.Scan(&d.ID, &d.ProjectID, &d.Source, &d.Title, &d.Ref, &d.Chunk, &ca); err != nil {
|
||||
var own, ca string
|
||||
if err := rows.Scan(&d.ID, &own, &d.ProjectID, &d.Source, &d.Title, &d.Ref, &d.Chunk, &ca); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.Owner = own
|
||||
d.CreatedAt, _ = parseTime(ca)
|
||||
if len(q.Sources) > 0 && !containsStr(q.Sources, d.Source) {
|
||||
if !knowledgeVisible(owner, q, d) {
|
||||
continue
|
||||
}
|
||||
out = append(out, core.RetrievalHit{Doc: d, Score: 1.0, Kind: "fulltext"})
|
||||
@@ -284,6 +346,34 @@ func (s *Store) searchKnowledgeLike(q core.RetrievalQuery, topK int) ([]core.Ret
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// knowledgeVisible 共同过滤:租户(本人 + 共享)、项目、来源。
|
||||
func knowledgeVisible(owner string, q core.RetrievalQuery, d core.KnowledgeDoc) bool {
|
||||
if d.Owner != "" && d.Owner != owner {
|
||||
return false
|
||||
}
|
||||
if q.ProjectID != "" && d.ProjectID != "" && d.ProjectID != q.ProjectID {
|
||||
return false
|
||||
}
|
||||
if len(q.Sources) > 0 && !containsStr(q.Sources, d.Source) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type rowScanner interface{ Scan(dest ...any) error }
|
||||
|
||||
func scanKnowledgeHit(rows rowScanner) (core.RetrievalHit, error) {
|
||||
var d core.KnowledgeDoc
|
||||
var own, ca string
|
||||
var score float64
|
||||
if err := rows.Scan(&d.ID, &own, &d.ProjectID, &d.Source, &d.Title, &d.Ref, &d.Chunk, &ca, &score); err != nil {
|
||||
return core.RetrievalHit{}, err
|
||||
}
|
||||
d.Owner = own
|
||||
d.CreatedAt, _ = parseTime(ca)
|
||||
return core.RetrievalHit{Doc: d, Score: -score, Kind: "fulltext"}, nil
|
||||
}
|
||||
|
||||
// ftsQuery 做最小安全化:按空白切词加引号,FTS5 前缀匹配。
|
||||
func ftsQuery(text string) string {
|
||||
words := splitWords(text)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// dialect 收敛 SQLite / PostgreSQL 差异。全部查询以 ? 占位符书写,PG 执行前 rebind。
|
||||
type dialect interface {
|
||||
name() string
|
||||
rebind(query string) string
|
||||
// lockConversation 在事务内锁定会话的事件序列分配(跨实例安全)。
|
||||
// PG:事务级咨询锁;SQLite:单写连接天然串行,无需额外锁。
|
||||
lockConversation(tx *sql.Tx, conversationID string) error
|
||||
// lockScope 锁定设置 scope 的读改写(owner+scope)。
|
||||
lockScope(tx *sql.Tx, owner, scope string) error
|
||||
}
|
||||
|
||||
// ---- SQLite(嵌入式档)----
|
||||
|
||||
type sqliteDialect struct{}
|
||||
|
||||
func (sqliteDialect) name() string { return "sqlite" }
|
||||
func (sqliteDialect) rebind(q string) string { return q }
|
||||
func (sqliteDialect) lockConversation(*sql.Tx, string) error { return nil }
|
||||
func (sqliteDialect) lockScope(*sql.Tx, string, string) error { return nil }
|
||||
|
||||
// ---- PostgreSQL(多用户/多实例档)----
|
||||
|
||||
type postgresDialect struct{}
|
||||
|
||||
func (postgresDialect) name() string { return "postgres" }
|
||||
|
||||
func (postgresDialect) rebind(q string) string {
|
||||
var b strings.Builder
|
||||
n := 0
|
||||
for i := 0; i < len(q); i++ {
|
||||
if q[i] == '?' {
|
||||
n++
|
||||
fmt.Fprintf(&b, "$%d", n)
|
||||
continue
|
||||
}
|
||||
b.WriteByte(q[i])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (postgresDialect) lockConversation(tx *sql.Tx, conversationID string) error {
|
||||
_, err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext($1))`, "conv:"+conversationID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (postgresDialect) lockScope(tx *sql.Tx, owner, scope string) error {
|
||||
_, err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext($1))`, "scope:"+owner+"/"+scope)
|
||||
return err
|
||||
}
|
||||
+123
-37
@@ -4,32 +4,53 @@ import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// isUniqueViolation 判定唯一约束冲突(PG 23505 / SQLite UNIQUE)。
|
||||
func isUniqueViolation(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "23505") ||
|
||||
strings.Contains(msg, "UNIQUE constraint") ||
|
||||
strings.Contains(msg, "duplicate key value")
|
||||
}
|
||||
|
||||
// ---- 消息 ----
|
||||
|
||||
// AppendMessage 以单语句原子分配会话内序号(INSERT ... SELECT MAX+1);
|
||||
// 并发同会话追加由 uq_messages_conv_seq 唯一约束兜底,冲突有界重试。
|
||||
func (s *Store) AppendMessage(m *core.Message) error {
|
||||
if m.ID == "" {
|
||||
m.ID = newID("msg")
|
||||
}
|
||||
m.CreatedAt = m.CreatedAt.UTC()
|
||||
var next int
|
||||
if err := s.db.QueryRow(`SELECT COALESCE(MAX(seq),0)+1 FROM messages WHERE conversation_id=?`, m.ConversationID).Scan(&next); err != nil {
|
||||
return err
|
||||
insert := `INSERT INTO messages(id,conversation_id,run_id,role,parts,seq,created_at)
|
||||
SELECT ?,?,?,?,?,COALESCE(MAX(seq),0)+1,? FROM messages WHERE conversation_id=?`
|
||||
for attempt := 0; attempt < 8; attempt++ {
|
||||
_, err := s.db.Exec(s.q(insert), m.ID, m.ConversationID, m.RunID, string(m.Role), marshalJSON(m.Parts), nowISO(), m.ConversationID)
|
||||
if err == nil {
|
||||
s.TouchConversation(m.ConversationID)
|
||||
return nil
|
||||
}
|
||||
if !isUniqueViolation(err) {
|
||||
return err
|
||||
}
|
||||
// 并发同会话追加冲突(PG 多连接竞态):短抖动退避后重算序号。
|
||||
time.Sleep(time.Duration(attempt) * time.Millisecond)
|
||||
}
|
||||
_, err := s.db.Exec(`INSERT INTO messages(id,conversation_id,run_id,role,parts,seq,created_at) VALUES(?,?,?,?,?,?,?)`,
|
||||
m.ID, m.ConversationID, m.RunID, string(m.Role), marshalJSON(m.Parts), next, nowISO())
|
||||
if err == nil {
|
||||
s.TouchConversation(m.ConversationID)
|
||||
}
|
||||
return err
|
||||
return fmt.Errorf("store: message seq conflict retries exhausted (conversation %s)", m.ConversationID)
|
||||
}
|
||||
|
||||
func (s *Store) ListMessages(conversationID string) ([]core.Message, error) {
|
||||
rows, err := s.db.Query(`SELECT id,conversation_id,run_id,role,parts,created_at FROM messages WHERE conversation_id=? ORDER BY seq`, conversationID)
|
||||
rows, err := s.db.Query(s.q(`SELECT id,conversation_id,run_id,role,parts,created_at FROM messages WHERE conversation_id=? ORDER BY seq`), conversationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -60,15 +81,15 @@ func (s *Store) LastMessages(conversationID string, n int) ([]core.Message, erro
|
||||
return all, nil
|
||||
}
|
||||
|
||||
// ---- Runs ----
|
||||
// ---- Runs(owner 冗余自会话;GetRun 面向 API 带租户过滤,内部路径用 getRunByID)----
|
||||
|
||||
func (s *Store) CreateRun(r *core.Run) error {
|
||||
if r.ID == "" {
|
||||
r.ID = newID("run")
|
||||
}
|
||||
r.CreatedAt = r.CreatedAt.UTC()
|
||||
_, err := s.db.Exec(`INSERT INTO runs(id,parent_run_id,conversation_id,project_id,kind,profile_id,status,phase,agent_name,created_at) VALUES(?,?,?,?,?,?,?,?,?,?)`,
|
||||
r.ID, r.ParentRunID, r.ConversationID, r.ProjectID, string(r.Kind), r.ProfileID, string(r.Status), string(r.Phase), r.AgentName, nowISO())
|
||||
_, err := s.db.Exec(s.q(`INSERT INTO runs(id,parent_run_id,conversation_id,project_id,owner,kind,profile_id,status,phase,agent_name,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)`),
|
||||
r.ID, r.ParentRunID, r.ConversationID, r.ProjectID, r.Owner, string(r.Kind), r.ProfileID, string(r.Status), string(r.Phase), r.AgentName, nowISO())
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -77,24 +98,27 @@ func (s *Store) UpdateRun(r *core.Run) error {
|
||||
if r.EndedAt != nil {
|
||||
ended = nowISO()
|
||||
}
|
||||
_, err := s.db.Exec(`UPDATE runs SET status=?, phase=?, usage=?, error=?, ended_at=? WHERE id=?`,
|
||||
_, err := s.db.Exec(s.q(`UPDATE runs SET status=?, phase=?, usage=?, error=?, ended_at=? WHERE id=?`),
|
||||
string(r.Status), string(r.Phase), marshalJSON(r.Usage), marshalJSON(r.Error), ended, r.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const runColumns = `id,parent_run_id,conversation_id,project_id,kind,profile_id,status,phase,agent_name,created_at,ended_at,usage,error`
|
||||
const runColumns = `id,parent_run_id,conversation_id,project_id,owner,kind,profile_id,status,phase,agent_name,created_at,ended_at,usage,error`
|
||||
|
||||
func scanRun(scan func(...any) error) (core.Run, error) {
|
||||
var r core.Run
|
||||
var parent, profile, phase, agent, ended, usage, errJSON *string
|
||||
var parent, owner, profile, phase, agent, ended, usage, errJSON *string
|
||||
var kind, status string
|
||||
var ca string
|
||||
if err := scan(&r.ID, &parent, &r.ConversationID, &r.ProjectID, &kind, &profile, &status, &phase, &agent, &ca, &ended, &usage, &errJSON); err != nil {
|
||||
if err := scan(&r.ID, &parent, &r.ConversationID, &r.ProjectID, &owner, &kind, &profile, &status, &phase, &agent, &ca, &ended, &usage, &errJSON); err != nil {
|
||||
return r, err
|
||||
}
|
||||
if parent != nil {
|
||||
r.ParentRunID = *parent
|
||||
}
|
||||
if owner != nil && *owner != "" {
|
||||
r.Owner = *owner
|
||||
}
|
||||
if profile != nil {
|
||||
r.ProfileID = *profile
|
||||
}
|
||||
@@ -120,21 +144,30 @@ func scanRun(scan func(...any) error) (core.Run, error) {
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetRun(id string) (core.Run, error) {
|
||||
// GetRun 面向 API 的租户过滤读取(跨租户 = NotFound)。
|
||||
func (s *Store) GetRun(owner, id string) (core.Run, error) {
|
||||
r, err := scanRun(func(dst ...any) error {
|
||||
return s.db.QueryRow(`SELECT `+runColumns+` FROM runs WHERE id=?`, id).Scan(dst...)
|
||||
return s.db.QueryRow(s.q(`SELECT `+runColumns+` FROM runs WHERE id=? AND owner=?`), id, owner).Scan(dst...)
|
||||
})
|
||||
if err != nil {
|
||||
if err.Error() == "sql: no rows" {
|
||||
return r, ErrNotFound
|
||||
}
|
||||
return r, err
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return r, ErrNotFound
|
||||
}
|
||||
return r, nil
|
||||
return r, err
|
||||
}
|
||||
|
||||
// GetRunByID 内部读取,无租户过滤(runmgr 生命周期管理;API 层必须用 GetRun)。
|
||||
func (s *Store) GetRunByID(id string) (core.Run, error) {
|
||||
r, err := scanRun(func(dst ...any) error {
|
||||
return s.db.QueryRow(s.q(`SELECT `+runColumns+` FROM runs WHERE id=?`), id).Scan(dst...)
|
||||
})
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return r, ErrNotFound
|
||||
}
|
||||
return r, err
|
||||
}
|
||||
|
||||
func (s *Store) ListRunsByConversation(conversationID string) ([]core.Run, error) {
|
||||
rows, err := s.db.Query(`SELECT `+runColumns+` FROM runs WHERE conversation_id=? ORDER BY created_at`, conversationID)
|
||||
rows, err := s.db.Query(s.q(`SELECT `+runColumns+` FROM runs WHERE conversation_id=? ORDER BY created_at`), conversationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -152,7 +185,7 @@ func (s *Store) ListRunsByConversation(conversationID string) ([]core.Run, error
|
||||
|
||||
// ChildRuns 返回直接子 run(任务树)。
|
||||
func (s *Store) ChildRuns(runID string) ([]core.Run, error) {
|
||||
rows, err := s.db.Query(`SELECT `+runColumns+` FROM runs WHERE parent_run_id=? ORDER BY created_at`, runID)
|
||||
rows, err := s.db.Query(s.q(`SELECT `+runColumns+` FROM runs WHERE parent_run_id=? ORDER BY created_at`), runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -168,23 +201,55 @@ func (s *Store) ChildRuns(runID string) ([]core.Run, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---- 事件(seq 游标权威)----
|
||||
// ---- 事件(会话内连续 seq 游标权威;批量落盘降低写放大)----
|
||||
|
||||
func (s *Store) AppendEvent(e *core.StreamEvent, payload any) (int64, error) {
|
||||
var payloadStr any
|
||||
if payload != nil {
|
||||
payloadStr = marshalJSON(payload)
|
||||
// AppendEvents 在单事务内追加一批事件并分配连续 seq(base+1..base+n),
|
||||
// 成功后回填 evs[i].Seq。事务内锁定会话序列:PG 用事务级咨询锁(跨实例安全),
|
||||
// SQLite 单写连接天然串行。空批次为空操作。
|
||||
func (s *Store) AppendEvents(conversationID string, evs []core.StreamEvent) (int64, error) {
|
||||
if len(evs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
res, err := s.db.Exec(`INSERT INTO events(conversation_id,run_id,ts,v,type,payload) VALUES(?,?,?,?,?,?)`,
|
||||
e.ConversationID, e.RunID, e.TS, e.V, string(e.Type), payloadStr)
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
defer tx.Rollback()
|
||||
if err := s.dialect.lockConversation(tx, conversationID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var base sql.NullInt64
|
||||
if err := tx.QueryRow(s.q(`SELECT COALESCE(MAX(seq),0) FROM events WHERE conversation_id=?`), conversationID).Scan(&base); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString(`INSERT INTO events(conversation_id,seq,run_id,ts,v,type,payload) VALUES `)
|
||||
args := make([]any, 0, len(evs)*7)
|
||||
for i := range evs {
|
||||
if i > 0 {
|
||||
sb.WriteString(",")
|
||||
}
|
||||
sb.WriteString("(?,?,?,?,?,?,?)")
|
||||
var payload any
|
||||
if len(evs[i].Payload) > 0 {
|
||||
payload = []byte(evs[i].Payload)
|
||||
}
|
||||
args = append(args, conversationID, base.Int64+int64(i)+1, evs[i].RunID, evs[i].TS, evs[i].V, string(evs[i].Type), payload)
|
||||
}
|
||||
if _, err := tx.Exec(s.q(sb.String()), args...); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for i := range evs {
|
||||
evs[i].Seq = base.Int64 + int64(i) + 1
|
||||
}
|
||||
return base.Int64, nil
|
||||
}
|
||||
|
||||
func (s *Store) EventsAfter(conversationID string, since int64, limit int) ([]core.StreamEvent, error) {
|
||||
rows, err := s.db.Query(`SELECT seq,run_id,ts,v,type,payload FROM events WHERE conversation_id=? AND seq>? ORDER BY seq LIMIT ?`,
|
||||
rows, err := s.db.Query(s.q(`SELECT seq,run_id,ts,v,type,payload FROM events WHERE conversation_id=? AND seq>? ORDER BY seq LIMIT ?`),
|
||||
conversationID, since, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -208,9 +273,30 @@ func (s *Store) EventsAfter(conversationID string, since int64, limit int) ([]co
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// EventAt 读取单个事件(PG 档 NOTIFY 扇出的按序号取回路径)。
|
||||
func (s *Store) EventAt(conversationID string, seq int64) (core.StreamEvent, error) {
|
||||
var e core.StreamEvent
|
||||
var typ string
|
||||
var payload []byte
|
||||
err := s.db.QueryRow(s.q(`SELECT seq,run_id,ts,v,type,payload FROM events WHERE conversation_id=? AND seq=?`), conversationID, seq).
|
||||
Scan(&e.Seq, &e.RunID, &e.TS, &e.V, &typ, &payload)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return e, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return e, err
|
||||
}
|
||||
e.ConversationID = conversationID
|
||||
e.Type = core.StreamEventType(typ)
|
||||
if len(payload) > 0 {
|
||||
e.Payload = payload
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (s *Store) LastEventSeq(conversationID string) (int64, error) {
|
||||
var seq sql.NullInt64
|
||||
err := s.db.QueryRow(`SELECT MAX(seq) FROM events WHERE conversation_id=?`, conversationID).Scan(&seq)
|
||||
err := s.db.QueryRow(s.q(`SELECT MAX(seq) FROM events WHERE conversation_id=?`), conversationID).Scan(&seq)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// 版本化迁移(设计 446:每版本附回滚;双档 CI 验证)。
|
||||
//
|
||||
// 纪律:
|
||||
// - 迁移严格只增不改:已发布的迁移语句不可修改,修正走新版本。
|
||||
// - v1 基线 = 原 CREATE IF NOT EXISTS 集;对既有部署是空操作,对新建库先落旧形
|
||||
// 再演进,保证新旧库走完全相同的迁移路径。
|
||||
// - 回滚(down)尽力而为:多租户数据无法折回单租户形态的步骤在语句注释中标明。
|
||||
// - 并行分支合流时核对版本号唯一与顺序(数据可靠性标准:迁移验收)。
|
||||
|
||||
type migration struct {
|
||||
version int
|
||||
name string
|
||||
up map[string][]string // 方言 → 语句
|
||||
down map[string][]string
|
||||
}
|
||||
|
||||
var migrations = []migration{
|
||||
{version: 1, name: "baseline", up: map[string][]string{
|
||||
"sqlite": {
|
||||
`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`,
|
||||
},
|
||||
"postgres": {
|
||||
`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 BIGSERIAL PRIMARY KEY, 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)`,
|
||||
// PG 档全文检索在 v4 落 tsvector/trigram;基线不含 FTS5。
|
||||
},
|
||||
}},
|
||||
{version: 2, name: "tenancy_and_integrity", up: map[string][]string{
|
||||
"sqlite": {
|
||||
// 租户列:空串 = 单租户默认(兼容既有数据,多租户落地不改核心)。
|
||||
`ALTER TABLE projects ADD COLUMN owner TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE conversations ADD COLUMN owner TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE runs ADD COLUMN owner TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE artifacts ADD COLUMN owner TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE knowledge ADD COLUMN owner TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE hitl_cards ADD COLUMN owner TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE profiles ADD COLUMN owner TEXT NOT NULL DEFAULT ''`,
|
||||
// 消息序号唯一:并发追加同会话由约束兜底(先前的 SELECT MAX+1 竞态)。
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS uq_messages_conv_seq ON messages(conversation_id, seq)`,
|
||||
// 事件表重建:全局自增 → 会话内连续 seq(复合主键;批量落盘按会话分配序号)。
|
||||
`CREATE TABLE events_new (conversation_id TEXT NOT NULL, seq INTEGER NOT NULL, run_id TEXT NOT NULL DEFAULT '', ts TEXT NOT NULL, v INTEGER NOT NULL, type TEXT NOT NULL, payload TEXT, PRIMARY KEY(conversation_id, seq))`,
|
||||
`INSERT INTO events_new(conversation_id, seq, run_id, ts, v, type, payload) SELECT conversation_id, ROW_NUMBER() OVER (PARTITION BY conversation_id ORDER BY seq), run_id, ts, v, type, payload FROM events`,
|
||||
`DROP TABLE events`,
|
||||
`ALTER TABLE events_new RENAME TO events`,
|
||||
// 设置表重建:scope → (owner, scope) 复合主键(租户隔离的设置档)。
|
||||
`CREATE TABLE settings_new (owner TEXT NOT NULL DEFAULT '', scope TEXT NOT NULL, "values" TEXT NOT NULL, PRIMARY KEY(owner, scope))`,
|
||||
`INSERT INTO settings_new(owner, scope, "values") SELECT '', scope, "values" FROM settings`,
|
||||
`DROP TABLE settings`,
|
||||
`ALTER TABLE settings_new RENAME TO settings`,
|
||||
},
|
||||
"postgres": {
|
||||
`ALTER TABLE projects ADD COLUMN owner TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE conversations ADD COLUMN owner TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE runs ADD COLUMN owner TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE artifacts ADD COLUMN owner TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE knowledge ADD COLUMN owner TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE hitl_cards ADD COLUMN owner TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE profiles ADD COLUMN owner TEXT NOT NULL DEFAULT ''`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS uq_messages_conv_seq ON messages(conversation_id, seq)`,
|
||||
`CREATE TABLE events_new (conversation_id TEXT NOT NULL, seq BIGINT NOT NULL, run_id TEXT NOT NULL DEFAULT '', ts TEXT NOT NULL, v INTEGER NOT NULL, type TEXT NOT NULL, payload TEXT, PRIMARY KEY(conversation_id, seq))`,
|
||||
`INSERT INTO events_new(conversation_id, seq, run_id, ts, v, type, payload) SELECT conversation_id, ROW_NUMBER() OVER (PARTITION BY conversation_id ORDER BY seq), run_id, ts, v, type, payload FROM events`,
|
||||
`DROP TABLE events`,
|
||||
`ALTER TABLE events_new RENAME TO events`,
|
||||
`CREATE TABLE settings_new (owner TEXT NOT NULL DEFAULT '', scope TEXT NOT NULL, "values" TEXT NOT NULL, PRIMARY KEY(owner, scope))`,
|
||||
`INSERT INTO settings_new(owner, scope, "values") SELECT '', scope, "values" FROM settings`,
|
||||
`DROP TABLE settings`,
|
||||
`ALTER TABLE settings_new RENAME TO settings`,
|
||||
},
|
||||
}, down: map[string][]string{
|
||||
"sqlite": {
|
||||
`DROP INDEX IF EXISTS uq_messages_conv_seq`,
|
||||
`CREATE TABLE events_old (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)`,
|
||||
`INSERT INTO events_old(conversation_id, run_id, ts, v, type, payload) SELECT conversation_id, run_id, ts, v, type, payload FROM events`,
|
||||
`DROP TABLE events`,
|
||||
`ALTER TABLE events_old RENAME TO events`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_events_conv ON events(conversation_id, seq)`,
|
||||
// 回滚假定单租户(owner='');多 owner 的设置行无法折回,仅迁移 owner='' 行。
|
||||
`CREATE TABLE settings_old (scope TEXT PRIMARY KEY, "values" TEXT NOT NULL)`,
|
||||
`INSERT INTO settings_old(scope, "values") SELECT scope, "values" FROM settings WHERE owner=''`,
|
||||
`DROP TABLE settings`,
|
||||
`ALTER TABLE settings_old RENAME TO settings`,
|
||||
`ALTER TABLE profiles DROP COLUMN owner`,
|
||||
`ALTER TABLE hitl_cards DROP COLUMN owner`,
|
||||
`ALTER TABLE knowledge DROP COLUMN owner`,
|
||||
`ALTER TABLE artifacts DROP COLUMN owner`,
|
||||
`ALTER TABLE runs DROP COLUMN owner`,
|
||||
`ALTER TABLE conversations DROP COLUMN owner`,
|
||||
`ALTER TABLE projects DROP COLUMN owner`,
|
||||
},
|
||||
"postgres": {
|
||||
`DROP INDEX IF EXISTS uq_messages_conv_seq`,
|
||||
`CREATE TABLE events_old (seq BIGSERIAL PRIMARY KEY, 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)`,
|
||||
`INSERT INTO events_old(conversation_id, run_id, ts, v, type, payload) SELECT conversation_id, run_id, ts, v, type, payload FROM events`,
|
||||
`DROP TABLE events`,
|
||||
`ALTER TABLE events_old RENAME TO events`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_events_conv ON events(conversation_id, seq)`,
|
||||
`CREATE TABLE settings_old (scope TEXT PRIMARY KEY, "values" TEXT NOT NULL)`,
|
||||
`INSERT INTO settings_old(scope, "values") SELECT scope, "values" FROM settings WHERE owner=''`,
|
||||
`DROP TABLE settings`,
|
||||
`ALTER TABLE settings_old RENAME TO settings`,
|
||||
`ALTER TABLE profiles DROP COLUMN owner`,
|
||||
`ALTER TABLE hitl_cards DROP COLUMN owner`,
|
||||
`ALTER TABLE knowledge DROP COLUMN owner`,
|
||||
`ALTER TABLE artifacts DROP COLUMN owner`,
|
||||
`ALTER TABLE runs DROP COLUMN owner`,
|
||||
`ALTER TABLE conversations DROP COLUMN owner`,
|
||||
`ALTER TABLE projects DROP COLUMN owner`,
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
// v3 认证与租户(阶段 1):user 即租户 owner;API key 只存 SHA-256 摘要。
|
||||
var migV3Up = []string{
|
||||
`CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, owner TEXT NOT NULL UNIQUE, name TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL)`,
|
||||
`CREATE TABLE IF NOT EXISTS api_keys (key_hash TEXT PRIMARY KEY, owner TEXT NOT NULL, name TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, last_used_at TEXT, revoked INTEGER NOT NULL DEFAULT 0)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_api_keys_owner ON api_keys(owner)`,
|
||||
}
|
||||
var migV3Down = []string{
|
||||
`DROP TABLE IF EXISTS api_keys`,
|
||||
`DROP TABLE IF EXISTS users`,
|
||||
}
|
||||
|
||||
// v4 PG 档全文检索(阶段 1):tsvector 生成列 + trigram GIN(中文子串兜底加速)。
|
||||
// SQLite 档为空操作(FTS5 已在基线)。
|
||||
var migV4PGUp = []string{
|
||||
`CREATE EXTENSION IF NOT EXISTS pg_trgm`,
|
||||
`ALTER TABLE knowledge ADD COLUMN IF NOT EXISTS tsv tsvector GENERATED ALWAYS AS (to_tsvector('simple', coalesce(title,'') || ' ' || coalesce(chunk,''))) STORED`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_knowledge_tsv ON knowledge USING gin (tsv)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_knowledge_chunk_trgm ON knowledge USING gin (chunk gin_trgm_ops)`,
|
||||
}
|
||||
var migV4PGDown = []string{
|
||||
`DROP INDEX IF EXISTS idx_knowledge_chunk_trgm`,
|
||||
`DROP INDEX IF EXISTS idx_knowledge_tsv`,
|
||||
`ALTER TABLE knowledge DROP COLUMN IF EXISTS tsv`,
|
||||
}
|
||||
|
||||
func init() {
|
||||
migrations = append(migrations,
|
||||
migration{version: 3, name: "auth_tenancy",
|
||||
up: map[string][]string{"sqlite": migV3Up, "postgres": migV3Up},
|
||||
down: map[string][]string{"sqlite": migV3Down, "postgres": migV3Down}},
|
||||
migration{version: 4, name: "pg_fulltext",
|
||||
up: map[string][]string{"sqlite": {`SELECT 1`}, "postgres": migV4PGUp},
|
||||
down: map[string][]string{"sqlite": {`SELECT 1`}, "postgres": migV4PGDown}},
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL)`); err != nil {
|
||||
return fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
applied := map[int]bool{}
|
||||
rows, err := s.db.Query(`SELECT version FROM schema_migrations`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var v int
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
rows.Close()
|
||||
return fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
applied[v] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
rows.Close()
|
||||
for _, m := range migrations {
|
||||
if applied[m.version] {
|
||||
continue
|
||||
}
|
||||
stmts := m.up[s.dialect.name()]
|
||||
if len(stmts) == 0 {
|
||||
return fmt.Errorf("migrate: v%d %s 缺少 %s 方言", m.version, m.name, s.dialect.name())
|
||||
}
|
||||
if err := s.execInTx(stmts); err != nil {
|
||||
return fmt.Errorf("migrate v%d (%s): %w", m.version, m.name, err)
|
||||
}
|
||||
if _, err := s.db.Exec(s.q(`INSERT INTO schema_migrations(version,name,applied_at) VALUES(?,?,?)`), m.version, m.name, nowISO()); err != nil {
|
||||
return fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) execInTx(stmts []string) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, q := range stmts {
|
||||
if _, err := tx.Exec(s.q(q)); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// MigrationStatus 返回已应用版本列表(诊断/验收用)。
|
||||
func (s *Store) MigrationStatus() ([]int, error) {
|
||||
rows, err := s.db.Query(`SELECT version FROM schema_migrations ORDER BY version`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []int
|
||||
for rows.Next() {
|
||||
var v int
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// MigrateDown 回滚到指定版本(不含;运维入口,多租户数据折回限制见各 down 注释)。
|
||||
func (s *Store) MigrateDown(toVersion int) error {
|
||||
applied, err := s.MigrationStatus()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := len(applied) - 1; i >= 0; i-- {
|
||||
v := applied[i]
|
||||
if v <= toVersion {
|
||||
break
|
||||
}
|
||||
var m *migration
|
||||
for j := range migrations {
|
||||
if migrations[j].version == v {
|
||||
m = &migrations[j]
|
||||
}
|
||||
}
|
||||
if m == nil {
|
||||
return fmt.Errorf("migrate-down: 未知版本 v%d", v)
|
||||
}
|
||||
stmts := m.down[s.dialect.name()]
|
||||
if len(stmts) == 0 {
|
||||
return fmt.Errorf("migrate-down: v%d 缺少 %s 回滚", v, s.dialect.name())
|
||||
}
|
||||
if err := s.execInTx(stmts); err != nil {
|
||||
return fmt.Errorf("migrate-down v%d (%s): %w", v, m.name, err)
|
||||
}
|
||||
if _, err := s.db.Exec(s.q(`DELETE FROM schema_migrations WHERE version=?`), v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+121
-60
@@ -8,23 +8,97 @@ import (
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("store: not found")
|
||||
// ---- 项目与会话(租户域:owner 过滤是硬边界,跨租户一律 NotFound)----
|
||||
|
||||
// ---- 项目与会话 ----
|
||||
|
||||
func (s *Store) CreateProject(name string) (core.Project, error) {
|
||||
p := core.Project{ID: newID("prj"), Name: name, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), Settings: map[string]any{}}
|
||||
_, err := s.db.Exec(`INSERT INTO projects(id,name,settings,created_at,updated_at) VALUES(?,?,?,?,?)`,
|
||||
p.ID, p.Name, "{}", nowISO(), nowISO())
|
||||
func (s *Store) CreateProject(owner, name string) (core.Project, error) {
|
||||
p := core.Project{ID: newID("prj"), Owner: owner, Name: name, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), Settings: map[string]any{}}
|
||||
_, err := s.db.Exec(s.q(`INSERT INTO projects(id,owner,name,settings,created_at,updated_at) VALUES(?,?,?,?,?,?)`),
|
||||
p.ID, owner, p.Name, "{}", nowISO(), nowISO())
|
||||
return p, err
|
||||
}
|
||||
|
||||
func (s *Store) ListProjects() ([]core.Project, error) {
|
||||
rows, err := s.db.Query(`SELECT id,name,settings,created_at,updated_at FROM projects ORDER BY updated_at DESC`)
|
||||
func (s *Store) ListProjects(owner string) ([]core.Project, error) {
|
||||
rows, err := s.db.Query(s.q(`SELECT id,name,settings,created_at,updated_at FROM projects WHERE owner=? ORDER BY updated_at DESC`), owner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanProjects(rows)
|
||||
}
|
||||
|
||||
func (s *Store) GetProject(owner, id string) (core.Project, error) {
|
||||
var p core.Project
|
||||
var settings, ca, ua string
|
||||
err := s.db.QueryRow(s.q(`SELECT id,name,settings,created_at,updated_at FROM projects WHERE id=? AND owner=?`), id, owner).
|
||||
Scan(&p.ID, &p.Name, &settings, &ca, &ua)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return p, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
p.Owner = owner
|
||||
p.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca)
|
||||
p.UpdatedAt, _ = time.Parse(time.RFC3339Nano, ua)
|
||||
if settings != "" && settings != "{}" {
|
||||
p.Settings, _ = unmarshalJSON[map[string]any](settings)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// UpdateProjectSettings 读改写走事务 + scope 锁:并发合并不再丢失更新。
|
||||
func (s *Store) UpdateProjectSettings(owner, id string, values map[string]any) error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if err := s.dialect.lockScope(tx, owner, "project:"+id); err != nil {
|
||||
return err
|
||||
}
|
||||
var settings string
|
||||
err = tx.QueryRow(s.q(`SELECT settings FROM projects WHERE id=? AND owner=?`), id, owner).Scan(&settings)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cur := map[string]any{}
|
||||
if settings != "" && settings != "{}" {
|
||||
cur, _ = unmarshalJSON[map[string]any](settings)
|
||||
}
|
||||
merged := core.MergeSettings(cur, values)
|
||||
res, err := tx.Exec(s.q(`UPDATE projects SET settings=?, updated_at=? WHERE id=? AND owner=?`), marshalJSON(merged), nowISO(), id, owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (s *Store) RenameProject(owner, id, name string) error {
|
||||
return s.updateProjectRow(owner, id, `UPDATE projects SET name=?, updated_at=? WHERE id=? AND owner=?`, name, nowISO(), id, owner)
|
||||
}
|
||||
|
||||
func (s *Store) DeleteProject(owner, id string) error {
|
||||
return s.updateProjectRow(owner, id, `DELETE FROM projects WHERE id=? AND owner=?`, id, owner)
|
||||
}
|
||||
|
||||
func (s *Store) updateProjectRow(owner, id, query string, args ...any) error {
|
||||
res, err := s.db.Exec(s.q(query), args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanProjects(rows *sql.Rows) ([]core.Project, error) {
|
||||
var out []core.Project
|
||||
for rows.Next() {
|
||||
var p core.Project
|
||||
@@ -43,54 +117,26 @@ func (s *Store) ListProjects() ([]core.Project, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetProject(id string) (core.Project, error) {
|
||||
var p core.Project
|
||||
var settings, ca, ua string
|
||||
err := s.db.QueryRow(`SELECT id,name,settings,created_at,updated_at FROM projects WHERE id=?`, id).
|
||||
Scan(&p.ID, &p.Name, &settings, &ca, &ua)
|
||||
// ---- 会话 ----
|
||||
|
||||
func (s *Store) CreateConversation(owner, projectID, title, profileID string) (core.Conversation, error) {
|
||||
// 项目归属校验:会话必须落在请求者的项目下。
|
||||
var one int
|
||||
err := s.db.QueryRow(s.q(`SELECT 1 FROM projects WHERE id=? AND owner=?`), projectID, owner).Scan(&one)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return p, ErrNotFound
|
||||
return core.Conversation{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return p, err
|
||||
return core.Conversation{}, err
|
||||
}
|
||||
p.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca)
|
||||
p.UpdatedAt, _ = time.Parse(time.RFC3339Nano, ua)
|
||||
if settings != "" && settings != "{}" {
|
||||
p.Settings, _ = unmarshalJSON[map[string]any](settings)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateProjectSettings(id string, values map[string]any) error {
|
||||
cur, err := s.GetProject(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
merged := core.MergeSettings(cur.Settings, values)
|
||||
_, err = s.db.Exec(`UPDATE projects SET settings=?, updated_at=? WHERE id=?`, marshalJSON(merged), nowISO(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) RenameProject(id, name string) error {
|
||||
_, err := s.db.Exec(`UPDATE projects SET name=?, updated_at=? WHERE id=?`, name, nowISO(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteProject(id string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM projects WHERE id=?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) CreateConversation(projectID, title, profileID string) (core.Conversation, error) {
|
||||
c := core.Conversation{ID: newID("cnv"), ProjectID: projectID, Title: title, ProfileID: profileID, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}
|
||||
_, err := s.db.Exec(`INSERT INTO conversations(id,project_id,title,profile_id,created_at,updated_at) VALUES(?,?,?,?,?,?)`,
|
||||
c.ID, c.ProjectID, c.Title, c.ProfileID, nowISO(), nowISO())
|
||||
c := core.Conversation{ID: newID("cnv"), ProjectID: projectID, Owner: owner, Title: title, ProfileID: profileID, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}
|
||||
_, err = s.db.Exec(s.q(`INSERT INTO conversations(id,project_id,owner,title,profile_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?)`),
|
||||
c.ID, c.ProjectID, owner, c.Title, c.ProfileID, nowISO(), nowISO())
|
||||
return c, err
|
||||
}
|
||||
|
||||
func (s *Store) ListConversations(projectID string) ([]core.Conversation, error) {
|
||||
rows, err := s.db.Query(`SELECT id,project_id,title,profile_id,created_at,updated_at FROM conversations WHERE project_id=? ORDER BY updated_at DESC`, projectID)
|
||||
func (s *Store) ListConversations(owner, projectID string) ([]core.Conversation, error) {
|
||||
rows, err := s.db.Query(s.q(`SELECT id,project_id,title,profile_id,created_at,updated_at FROM conversations WHERE project_id=? AND owner=? ORDER BY updated_at DESC`), projectID, owner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -102,6 +148,7 @@ func (s *Store) ListConversations(projectID string) ([]core.Conversation, error)
|
||||
if err := rows.Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &ca, &ua); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Owner = owner
|
||||
c.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca)
|
||||
c.UpdatedAt, _ = time.Parse(time.RFC3339Nano, ua)
|
||||
out = append(out, c)
|
||||
@@ -109,10 +156,10 @@ func (s *Store) ListConversations(projectID string) ([]core.Conversation, error)
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetConversation(id string) (core.Conversation, error) {
|
||||
func (s *Store) GetConversation(owner, id string) (core.Conversation, error) {
|
||||
var c core.Conversation
|
||||
var ca, ua string
|
||||
err := s.db.QueryRow(`SELECT id,project_id,title,profile_id,created_at,updated_at FROM conversations WHERE id=?`, id).
|
||||
err := s.db.QueryRow(s.q(`SELECT id,project_id,title,profile_id,created_at,updated_at FROM conversations WHERE id=? AND owner=?`), id, owner).
|
||||
Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &ca, &ua)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return c, ErrNotFound
|
||||
@@ -120,23 +167,37 @@ func (s *Store) GetConversation(id string) (core.Conversation, error) {
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
c.Owner = owner
|
||||
c.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca)
|
||||
c.UpdatedAt, _ = time.Parse(time.RFC3339Nano, ua)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *Store) TouchConversation(id string) { s.db.Exec(`UPDATE conversations SET updated_at=? WHERE id=?`, nowISO(), id) }
|
||||
// TouchConversation 刷新会话 updated_at(内部路径;归属已在上游校验)。
|
||||
func (s *Store) TouchConversation(id string) {
|
||||
s.db.Exec(s.q(`UPDATE conversations SET updated_at=? WHERE id=?`), nowISO(), id)
|
||||
}
|
||||
|
||||
func (s *Store) UpdateConversation(id, title, profileID string) error {
|
||||
func (s *Store) UpdateConversation(owner, id, title, profileID string) error {
|
||||
var query string
|
||||
var args []any
|
||||
if title == "" {
|
||||
_, err := s.db.Exec(`UPDATE conversations SET profile_id=?, updated_at=? WHERE id=?`, profileID, nowISO(), id)
|
||||
query = `UPDATE conversations SET profile_id=?, updated_at=? WHERE id=? AND owner=?`
|
||||
args = []any{profileID, nowISO(), id, owner}
|
||||
} else {
|
||||
query = `UPDATE conversations SET title=?, profile_id=?, updated_at=? WHERE id=? AND owner=?`
|
||||
args = []any{title, profileID, nowISO(), id, owner}
|
||||
}
|
||||
res, err := s.db.Exec(s.q(query), args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.db.Exec(`UPDATE conversations SET title=?, profile_id=?, updated_at=? WHERE id=?`, title, profileID, nowISO(), id)
|
||||
return err
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteConversation(id string) error {
|
||||
_, err := s.db.Exec(`DELETE FROM conversations WHERE id=?`, id)
|
||||
return err
|
||||
func (s *Store) DeleteConversation(owner, id string) error {
|
||||
return s.updateProjectRow(owner, id, `DELETE FROM conversations WHERE id=? AND owner=?`, id, owner)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// searchKnowledgePG:PG 档知识检索(设计 D16)。
|
||||
// 单查询混合:tsvector('simple' 配置,词级)OR trigram LIKE 子串(中文兜底;
|
||||
// LIKE 由 pg_trgm GIN 索引加速)。排序:ts_rank 优先,命中时间次之。
|
||||
func (s *Store) searchKnowledgePG(owner string, q core.RetrievalQuery) ([]core.RetrievalHit, error) {
|
||||
topK := q.TopK
|
||||
if topK <= 0 {
|
||||
topK = 5
|
||||
}
|
||||
text := q.Text
|
||||
rows, err := s.db.Query(s.q(`
|
||||
SELECT k.id,k.owner,k.project_id,k.source,k.title,k.ref,k.chunk,k.created_at,
|
||||
ts_rank(k.tsv, websearch_to_tsquery('simple', ?)) AS r
|
||||
FROM knowledge k
|
||||
WHERE (k.owner=? OR k.owner='')
|
||||
AND (? = '' OR k.tsv @@ websearch_to_tsquery('simple', ?) OR k.chunk LIKE ? OR k.title LIKE ?)
|
||||
ORDER BY r DESC, k.created_at DESC
|
||||
LIMIT ?`), text, owner, text, text, "%"+text+"%", "%"+text+"%", topK*2)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []core.RetrievalHit
|
||||
for rows.Next() {
|
||||
var d core.KnowledgeDoc
|
||||
var own, ca string
|
||||
var rank float64
|
||||
if err := rows.Scan(&d.ID, &own, &d.ProjectID, &d.Source, &d.Title, &d.Ref, &d.Chunk, &ca, &rank); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.Owner = own
|
||||
d.CreatedAt, _ = parseTime(ca)
|
||||
if !knowledgeVisible(owner, q, d) {
|
||||
continue
|
||||
}
|
||||
score := rank
|
||||
if score == 0 {
|
||||
score = 0.5 // 纯子串命中(trigram/中文兜底)给保底分
|
||||
}
|
||||
out = append(out, core.RetrievalHit{Doc: d, Score: score, Kind: "fulltext"})
|
||||
if len(out) >= topK {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
+59
-83
@@ -1,26 +1,39 @@
|
||||
// Package store 提供后端持久化:默认档为嵌入式 SQLite(单文件,零外部服务)。
|
||||
// 存储档接口按设计 4.1/D16 预留 PostgreSQL 档;业务层只依赖本包公开方法。
|
||||
// Package store 是持久化层唯一入口(存储档接口,设计 4.1/D5/D16)。
|
||||
// 两档实现共用同一方法面:
|
||||
// - 嵌入式档 SQLite(默认:单文件 + 本地对象目录,零外部服务);
|
||||
// - PostgreSQL 档(多用户/多实例;DSN 配置,pgx 纯 Go 驱动,静态构建兼容)。
|
||||
//
|
||||
// 方言差异(占位符、UPSERT、全文检索、备份、咨询锁)收敛在 dialect 与方言文件;
|
||||
// 业务层只依赖本包公开方法。schema 演进走版本化迁移(migrate.go,双方言、可回滚)。
|
||||
// 租户硬边界:租户域查询一律带 owner 过滤,跨租户访问返回 ErrNotFound。
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
_ "github.com/jackc/pgx/v5/stdlib" // PG 档驱动(注册名 "pgx")
|
||||
_ "modernc.org/sqlite" // 嵌入式档驱动(注册名 "sqlite",CGO-free)
|
||||
)
|
||||
|
||||
// Store 持有数据库连接与对象目录。所有方法并发安全(SQLite 单写者,靠 busy 重试)。
|
||||
var ErrNotFound = errors.New("store: not found")
|
||||
|
||||
// OwnerDefault 是单租户默认 owner(嵌入式档 / 未启用认证时的隐式身份)。
|
||||
const OwnerDefault = "local"
|
||||
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
dataDir string // 对象目录:data/artifacts/<id>
|
||||
db *sql.DB
|
||||
dialect dialect
|
||||
dataDir string // 工件对象目录(<data>/artifacts 或配置的共享目录)
|
||||
}
|
||||
|
||||
// Open 打开(必要时创建)SQLite 存储并执行迁移。
|
||||
func Open(path string) (*Store, error) {
|
||||
// OpenSQLite 打开(必要时创建)嵌入式存储并执行迁移。
|
||||
func OpenSQLite(path string) (*Store, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -29,14 +42,32 @@ func Open(path string) (*Store, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// SQLite 单写者:串行化写连接,避免 database is locked。
|
||||
// SQLite 单写者:串行化连接,语句级原子 + 版本化迁移事务。
|
||||
db.SetMaxOpenConns(1)
|
||||
s := &Store{db: db, dataDir: filepath.Join(filepath.Dir(path), "artifacts")}
|
||||
return finishOpen(db, sqliteDialect{}, filepath.Join(filepath.Dir(path), "artifacts"))
|
||||
}
|
||||
|
||||
// OpenPostgres 打开 PostgreSQL 档存储并执行迁移。
|
||||
// DSN 例:postgres://user:pass@host:5432/agent_system?sslmode=disable
|
||||
// artifactsDir 为工件对象目录(多实例部署时指向共享卷;单实例默认本地)。
|
||||
func OpenPostgres(dsn, artifactsDir string) (*Store, error) {
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(16)
|
||||
db.SetMaxIdleConns(8)
|
||||
db.SetConnMaxIdleTime(5 * time.Minute)
|
||||
return finishOpen(db, postgresDialect{}, artifactsDir)
|
||||
}
|
||||
|
||||
func finishOpen(db *sql.DB, d dialect, artifactsDir string) (*Store, error) {
|
||||
s := &Store{db: db, dialect: d, dataDir: artifactsDir}
|
||||
if err := s.migrate(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(s.dataDir, 0o755); err != nil {
|
||||
if err := os.MkdirAll(artifactsDir, 0o755); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
@@ -45,81 +76,28 @@ func Open(path string) (*Store, error) {
|
||||
|
||||
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`,
|
||||
// Dialect 返回当前存储档名(sqlite | postgres;诊断/健康检查用)。
|
||||
func (s *Store) Dialect() string { return s.dialect.name() }
|
||||
|
||||
// DataDir 返回工件对象目录。
|
||||
func (s *Store) DataDir() string { return s.dataDir }
|
||||
|
||||
// q 按方言重绑占位符(查询统一用 ? 书写,PG 执行前转 $n)。
|
||||
// 本包 SQL 不含字符串字面量 "?",重绑安全。
|
||||
func (s *Store) q(query string) string { return s.dialect.rebind(query) }
|
||||
|
||||
// Backup 生成一致性备份文件。SQLite 档用 VACUUM INTO(在线安全,含 WAL 内容);
|
||||
// PG 档备份走 pg_dump / WAL 归档(运维路径,见 usage 指南),此处返回指引错误。
|
||||
func (s *Store) Backup(destPath string) error {
|
||||
if s.dialect.name() != "sqlite" {
|
||||
return errors.New("store: PG 档备份走 pg_dump/WAL 归档(见 docs/guides/usage.md 存储档一节)")
|
||||
}
|
||||
for _, q := range stmts {
|
||||
if _, err := s.db.Exec(q); err != nil {
|
||||
return fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
if _, err := s.db.Exec(`VACUUM INTO ?`, destPath); err != nil {
|
||||
return fmt.Errorf("store: backup: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- 通用 ----
|
||||
|
||||
func nowISO() string { return time.Now().UTC().Format(time.RFC3339Nano) }
|
||||
|
||||
func marshalJSON(v any) string {
|
||||
@@ -132,5 +110,3 @@ func unmarshalJSON[T any](raw string) (T, error) {
|
||||
err := json.Unmarshal([]byte(raw), &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
var _ = unmarshalJSON[map[string]any]
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// openTestStore 双档测试入口:设 AGENT_PG_TEST_DSN 时对 PostgreSQL 档逐用例建独立
|
||||
// schema 运行(双档 CI 验证,D16);默认嵌入式 SQLite 档。
|
||||
func openTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("AGENT_PG_TEST_DSN")
|
||||
if dsn == "" {
|
||||
st, err := OpenSQLite(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
return st
|
||||
}
|
||||
// 每用例独立 schema(随机名),测试后级联清理,避免跨用例/跨运行干扰。
|
||||
schema := "t_" + strings.ToLower(newID("s")) + "_" + strconv.Itoa(os.Getpid())
|
||||
admin, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
|
||||
admin.Close()
|
||||
t.Fatalf("create schema: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE")
|
||||
admin.Close()
|
||||
})
|
||||
st, err := OpenPostgres(dsn+"&search_path="+schema, filepath.Join(t.TempDir(), "artifacts"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
return st
|
||||
}
|
||||
|
||||
func isSQLite(st *Store) bool { return st.Dialect() == "sqlite" }
|
||||
|
||||
// 迁移验收:全量应用 → 状态 1..4 → 回滚到 1(老库形态)→ 再次前进(既有部署升级路径)。
|
||||
func TestMigrationUpgradesExistingDatabase(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
got, err := st.MigrationStatus()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("expected versions [1 2 3 4], got %v", got)
|
||||
}
|
||||
// 老库形态(v2 前):events 全局自增、settings 单列主键。
|
||||
if err := st.MigrateDown(1); err != nil {
|
||||
t.Fatalf("migrate down: %v", err)
|
||||
}
|
||||
var seqDefault any
|
||||
if err := st.db.QueryRow(`SELECT COALESCE((SELECT MAX(seq) FROM events), 0)`).Scan(&seqDefault); err != nil {
|
||||
t.Fatalf("old events table: %v", err)
|
||||
}
|
||||
// 写一行老形态数据再前进:升级必须保留数据。
|
||||
now := nowISO()
|
||||
if _, err := st.db.Exec(st.q(`INSERT INTO projects(id,name,settings,created_at,updated_at) VALUES('prj_old','老项目','{}',?,?)`), now, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.db.Exec(st.q(`INSERT INTO settings(scope,"values") VALUES('user','{"a":1}')`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.db.Exec(st.q(`INSERT INTO events(conversation_id,run_id,ts,v,type,payload) VALUES('c1','r1',?,1,'run.phase','{}')`), now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 重新前进(走真实升级路径)。
|
||||
if err := st.migrate(); err != nil {
|
||||
t.Fatalf("re-migrate: %v", err)
|
||||
}
|
||||
if p, err := st.GetProject("", "prj_old"); err != nil || p.Name != "老项目" {
|
||||
t.Fatalf("project survived upgrade: %v %v", p, err)
|
||||
}
|
||||
if m, err := st.GetSettingsScope("", "user"); err != nil || len(m) != 1 {
|
||||
t.Fatalf("settings survived upgrade: %v %v", m, err)
|
||||
}
|
||||
// 事件重编号为会话内连续 seq。
|
||||
evs, err := st.EventsAfter("c1", 0, 10)
|
||||
if err != nil || len(evs) != 1 || evs[0].Seq != 1 {
|
||||
t.Fatalf("event renumbered to per-conversation seq: %v %v", evs, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 租户隔离(负例):跨租户读写一律 NotFound,共享知识(owner='')双方可见。
|
||||
func TestTenantIsolation(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
a, b := "alice", "bob"
|
||||
|
||||
pa, err := st.CreateProject(a, "A的项目")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.GetProject(b, pa.ID); err != ErrNotFound {
|
||||
t.Fatalf("cross-tenant project read must be NotFound, got %v", err)
|
||||
}
|
||||
if err := st.RenameProject(b, pa.ID, "劫持"); err != ErrNotFound {
|
||||
t.Fatalf("cross-tenant project write must be NotFound, got %v", err)
|
||||
}
|
||||
ca, err := st.CreateConversation(a, pa.ID, "A的会话", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.GetConversation(b, ca.ID); err != ErrNotFound {
|
||||
t.Fatalf("cross-tenant conversation read must be NotFound, got %v", err)
|
||||
}
|
||||
if _, err := st.CreateConversation(b, pa.ID, "越权会话", ""); err != ErrNotFound {
|
||||
t.Fatalf("conversation in foreign project must be NotFound, got %v", err)
|
||||
}
|
||||
art := core.Artifact{Owner: a, ProjectID: pa.ID, Name: "a.txt", Kind: "data"}
|
||||
if err := st.CreateArtifact(&art, []byte("x")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.GetArtifact(b, art.ID); err != ErrNotFound {
|
||||
t.Fatalf("cross-tenant artifact read must be NotFound, got %v", err)
|
||||
}
|
||||
if _, err := st.ArtifactContent(b, art.ID); err != ErrNotFound {
|
||||
t.Fatalf("cross-tenant artifact content must be NotFound, got %v", err)
|
||||
}
|
||||
// 密钥与设置隔离。
|
||||
keyA, err := st.CreateAPIKey(a, "ka")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if owner, ok := st.AuthOwner(keyA); !ok || owner != a {
|
||||
t.Fatalf("auth owner: %q %v", owner, ok)
|
||||
}
|
||||
if _, ok := st.AuthOwner("ask_nonexistent"); ok {
|
||||
t.Fatal("unknown key must not authenticate")
|
||||
}
|
||||
if err := st.MergeSettingsScope(a, "user", map[string]any{"x": 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m, err := st.GetSettingsScope(b, "user"); err != nil || len(m) != 0 {
|
||||
t.Fatalf("settings must be tenant-scoped, got %v %v", m, err)
|
||||
}
|
||||
// 共享知识:owner='' 对双方可见。
|
||||
if err := st.AddKnowledge(core.KnowledgeDoc{Owner: "", Title: "共享", Chunk: "共享知识内容"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.AddKnowledge(core.KnowledgeDoc{Owner: a, Title: "A私有", Chunk: "A 私有知识内容"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hitsB, err := st.SearchKnowledge(b, core.RetrievalQuery{Text: "共享"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hitsB) == 0 {
|
||||
t.Fatal("shared knowledge must be visible to bob")
|
||||
}
|
||||
hitsB2, err := st.SearchKnowledge(b, core.RetrievalQuery{Text: "私有"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hitsB2) != 0 {
|
||||
t.Fatal("alice's private knowledge must not be visible to bob")
|
||||
}
|
||||
}
|
||||
|
||||
// 并发验收:同会话并发追加消息,seq 唯一且连续(先查后写竞态的约束兜底)。
|
||||
func TestConcurrentAppendMessageUniqueSeq(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
p, _ := st.CreateProject("o", "p")
|
||||
c, _ := st.CreateConversation("o", p.ID, "c", "")
|
||||
const n = 24
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, n)
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
m := core.Message{ConversationID: c.ID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "m"}}}
|
||||
if err := st.AppendMessage(&m); err != nil {
|
||||
errs <- err
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
t.Fatalf("concurrent append: %v", err)
|
||||
}
|
||||
msgs, err := st.ListMessages(c.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(msgs) != n {
|
||||
t.Fatalf("expected %d messages, got %d", n, len(msgs))
|
||||
}
|
||||
}
|
||||
|
||||
// 并发验收:同 scope 并发合并设置,键值不丢失(读改写事务 + 锁)。
|
||||
func TestConcurrentSettingsMergeNoLostUpdate(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
const writers = 8
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < writers; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
_ = st.MergeSettingsScope("o", "user", map[string]any{string(rune('a' + i)): i})
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
m, err := st.GetSettingsScope("o", "user")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m) != writers {
|
||||
t.Fatalf("lost update: expected %d keys, got %d (%v)", writers, len(m), m)
|
||||
}
|
||||
}
|
||||
|
||||
// 批量事件:连续 seq 分配 + 游标重放。
|
||||
func TestAppendEventsBatchSeq(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
mk := func(t2 string) core.StreamEvent {
|
||||
return core.StreamEvent{ConversationID: "c1", RunID: "r1", TS: nowISO(), V: 1, Type: core.StreamEventType(t2), Payload: []byte(`{}`)}
|
||||
}
|
||||
batch := []core.StreamEvent{mk("message.delta"), mk("message.delta"), mk("run.completed")}
|
||||
if _, err := st.AppendEvents("c1", batch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, want := range []int64{1, 2, 3} {
|
||||
if batch[i].Seq != want {
|
||||
t.Fatalf("seq[%d] = %d, want %d", i, batch[i].Seq, want)
|
||||
}
|
||||
}
|
||||
// 第二批接续。
|
||||
b2 := []core.StreamEvent{mk("message.delta")}
|
||||
if _, err := st.AppendEvents("c1", b2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b2[0].Seq != 4 {
|
||||
t.Fatalf("second batch seq = %d, want 4", b2[0].Seq)
|
||||
}
|
||||
evs, err := st.EventsAfter("c1", 2, 10)
|
||||
if err != nil || len(evs) != 2 {
|
||||
t.Fatalf("replay after seq=2: %v %v", evs, err)
|
||||
}
|
||||
if last, _ := st.LastEventSeq("c1"); last != 4 {
|
||||
t.Fatalf("last seq = %d", last)
|
||||
}
|
||||
// 独立会话互不影响(会话内 seq 各自从 1 开始)。
|
||||
b3 := []core.StreamEvent{mk("run.phase")}
|
||||
if _, err := st.AppendEvents("c2", b3); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b3[0].Seq != 1 {
|
||||
t.Fatalf("per-conversation seq expected, got %d", b3[0].Seq)
|
||||
}
|
||||
}
|
||||
|
||||
// HITL 原子 resolve:并发/重复 resolve 只成功一次。
|
||||
func TestHITLResolveAtomic(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
card := core.HITLCard{ID: "hitl_1", RunID: "r1", ConversationID: "c1", Question: "q", Kind: core.HITLKindChoice}
|
||||
if err := st.SaveHITLCard("o", &card); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
ok := make(chan bool, 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
err := st.ResolveHITLCard("o", "hitl_1", core.HITLResolvedPayload{Kind: "choice", Answer: "x"})
|
||||
ok <- err == nil
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
succeeded := 0
|
||||
for i := 0; i < 2; i++ {
|
||||
if <-ok {
|
||||
succeeded++
|
||||
}
|
||||
}
|
||||
if succeeded != 1 {
|
||||
t.Fatalf("double resolve must succeed exactly once, got %d", succeeded)
|
||||
}
|
||||
}
|
||||
|
||||
// 备份验收:VACUUM INTO 产出可打开的一致性副本。
|
||||
func TestBackup(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
p, _ := st.CreateProject("o", "p")
|
||||
dest := filepath.Join(t.TempDir(), "backup.db")
|
||||
err := st.Backup(dest)
|
||||
if !isSQLite(st) {
|
||||
// PG 档:备份走 pg_dump(运维路径),接口返回指引错误。
|
||||
if err == nil || !strings.Contains(err.Error(), "pg_dump") {
|
||||
t.Fatalf("PG tier backup must return guidance error, got %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 打开副本验证数据完整。
|
||||
cp, err := OpenSQLite(dest)
|
||||
if err != nil {
|
||||
t.Fatalf("open backup: %v", err)
|
||||
}
|
||||
defer cp.Close()
|
||||
if _, err := cp.GetProject("o", p.ID); err != nil {
|
||||
t.Fatalf("backup content: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 知识批量导入为单事务:全部成功(半批失败回滚由事务保证)。
|
||||
func TestAddKnowledgeBatch(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
docs := []core.KnowledgeDoc{
|
||||
{Owner: "o", Title: "t1", Chunk: "alpha beta"},
|
||||
{Owner: "o", Title: "t2", Chunk: "gamma delta"},
|
||||
}
|
||||
if err := st.AddKnowledge(docs...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hits, err := st.SearchKnowledge("o", core.RetrievalQuery{Text: "alpha"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("FTS must find alpha")
|
||||
}
|
||||
// 中文子串走 LIKE 兜底。
|
||||
if err := st.AddKnowledge(core.KnowledgeDoc{Owner: "o", Title: "中文", Chunk: "知识库检索中文兜底路径"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hits, err = st.SearchKnowledge("o", core.RetrievalQuery{Text: "中文兜底"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("CJK substring fallback must hit")
|
||||
}
|
||||
}
|
||||
|
||||
// 工件原子性:创建失败不留悬空行;内容更新走临时文件。
|
||||
func TestArtifactAtomicity(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
art := core.Artifact{Owner: "o", ProjectID: "p", Name: "a.txt", Kind: "data"}
|
||||
if err := st.CreateArtifact(&art, []byte("hello")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c, err := st.ArtifactContent("o", art.ID); err != nil || string(c) != "hello" {
|
||||
t.Fatalf("content: %q %v", c, err)
|
||||
}
|
||||
if err := st.UpdateArtifactContent("o", art.ID, []byte("world")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c, _ := st.ArtifactContent("o", art.ID); string(c) != "world" {
|
||||
t.Fatal("update lost")
|
||||
}
|
||||
// 删除联动。
|
||||
if err := st.DeleteArtifact("o", art.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.GetArtifact("o", art.ID); err != ErrNotFound {
|
||||
t.Fatalf("deleted artifact: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 画像租户边界:builtin 共享,用户画像隔离。
|
||||
func TestProfileTenantScope(t *testing.T) {
|
||||
st := openTestStore(t)
|
||||
if err := st.SaveProfile(core.AgentProfile{ID: "default", Builtin: true, SystemPrompt: "s", Name: "默认"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.SaveProfile(core.AgentProfile{ID: "prof_alice", Owner: "alice", SystemPrompt: "s", Name: "A"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
la, _ := st.ListProfiles("alice")
|
||||
if len(la) != 2 { // builtin + alice 自己
|
||||
t.Fatalf("alice profiles = %d", len(la))
|
||||
}
|
||||
lb, _ := st.ListProfiles("bob")
|
||||
if len(lb) != 1 { // 仅 builtin
|
||||
t.Fatalf("bob profiles = %d", len(lb))
|
||||
}
|
||||
if _, err := st.GetProfile("bob", "prof_alice"); err != ErrNotFound {
|
||||
t.Fatalf("cross-tenant profile: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user