Why: backend main pinned core 019617e (unfetchable post-migration) and the store List* functions returned nil slices that encode as JSON null, which crashed the frontend nav with .map on null (the 2026-09-30 white-screen). Evidence: go test ./... across all packages; store regression TestEmptyListsSerializeAsEmptyArrays asserts [] for empty project lists. Impact: all 17 store list sites now initialize empty slices; build-blocker core pin resolved; frontend no longer receives null for empty lists. Refs: documents/records/2026-09-30-history-migration.md
151 lines
4.5 KiB
Go
151 lines
4.5 KiB
Go
package store
|
||
|
||
import (
|
||
"database/sql"
|
||
"errors"
|
||
|
||
core "agent-system-core"
|
||
)
|
||
|
||
// ---- 画像(builtin=owner'' 共享;用户画像按 owner 隔离)----
|
||
|
||
func (s *Store) SaveProfile(p core.AgentProfile) error {
|
||
_, 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
|
||
}
|
||
|
||
// 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
|
||
}
|
||
defer rows.Close()
|
||
out := []core.AgentProfile{}
|
||
for rows.Next() {
|
||
var payload string
|
||
if err := rows.Scan(&payload); err != nil {
|
||
return nil, err
|
||
}
|
||
p, _ := unmarshalJSON[core.AgentProfile](payload)
|
||
out = append(out, p)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (s *Store) GetProfile(owner, id string) (core.AgentProfile, error) {
|
||
var payload string
|
||
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
|
||
}
|
||
if err != nil {
|
||
return core.AgentProfile{}, err
|
||
}
|
||
return unmarshalJSON[core.AgentProfile](payload)
|
||
}
|
||
|
||
func (s *Store) DeleteProfile(owner, id string) error {
|
||
var builtin int
|
||
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(s.q(`DELETE FROM profiles WHERE id=? AND owner=?`), id, owner)
|
||
return err
|
||
}
|
||
|
||
// ---- 设置(scope = user | project:<id>;PK=(owner,scope),租户隔离)----
|
||
|
||
func (s *Store) GetSettingsScope(owner, scope string) (map[string]any, error) {
|
||
var payload string
|
||
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
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out, err := unmarshalJSON[map[string]any](payload)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if out == nil {
|
||
out = map[string]any{}
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// 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 = 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
|
||
}
|
||
|
||
// ---- 插件注册表(节点级:无租户维度;加载状态在 pluginhost 内存态)----
|
||
|
||
func (s *Store) UpsertPlugin(id, dir string, m core.PluginManifest) error {
|
||
_, 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(s.q(`SELECT manifest FROM plugins ORDER BY id`))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer rows.Close()
|
||
out := []core.PluginManifest{}
|
||
for rows.Next() {
|
||
var payload string
|
||
if err := rows.Scan(&payload); err != nil {
|
||
return nil, err
|
||
}
|
||
m, _ := unmarshalJSON[core.PluginManifest](payload)
|
||
out = append(out, m)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (s *Store) RemovePlugin(id string) error {
|
||
_, err := s.db.Exec(s.q(`DELETE FROM plugins WHERE id=?`), id)
|
||
return err
|
||
}
|