Why: Account disable could be followed by late Backend business writes, API key creation could implicitly create users, account bootstrap locks could invert, and directory cursors were process-local or leaked out-of-scope changes. Evidence: Local go test ./... and isolated PostgreSQL package suites passed on the staged candidate; exact committed-head SELF replay and stage 6 human review remain pending. Impact: Adds final account transactions and external-effect checks across HTTP, Host, Bus and run manager; tightens API key issuance and persists versioned cursor signing. Delegated RDM rename still needs the separately proposed real-operator contract, so A1 and stage 6 are not complete. Refs: Documents U3 r1, approved A1-A4 amendment fb3a821, pending delegated-projection amendment 5783940, Dev feature/iam-u3-account-directory-recovery.
722 lines
23 KiB
Go
722 lines
23 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
core "agent-system-core"
|
|
)
|
|
|
|
// Secret 是一把带标签的服务密钥。列表接口不返回 Value。
|
|
type Secret struct {
|
|
ID string `json:"id"`
|
|
Owner string `json:"owner,omitempty"`
|
|
Service string `json:"service"`
|
|
Label string `json:"label"`
|
|
Active bool `json:"active"`
|
|
Mask string `json:"mask"`
|
|
CreatedAt string `json:"createdAt"`
|
|
Value string `json:"-"`
|
|
}
|
|
|
|
// ContextEntry 是一条条件注入。常驻或关键词命中后附在上下文末尾。
|
|
type ContextEntry struct {
|
|
ID string `json:"id"`
|
|
Owner string `json:"owner,omitempty"`
|
|
ProjectID string `json:"projectId,omitempty"`
|
|
ConversationID string `json:"conversationId,omitempty"`
|
|
Keys []string `json:"keys"`
|
|
Secondary string `json:"secondary,omitempty"`
|
|
Logic string `json:"logic"`
|
|
Content string `json:"content"`
|
|
Constant bool `json:"constant"`
|
|
ScanDepth int `json:"scanDepth"`
|
|
SortOrder int `json:"sortOrder"`
|
|
BudgetChars int `json:"budgetChars"`
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
|
|
// UserCommand 是用户自定义的 / 命令,展开成提示词,不自动触发。
|
|
type UserCommand struct {
|
|
ID string `json:"id"`
|
|
Owner string `json:"owner,omitempty"`
|
|
Name string `json:"name"`
|
|
Title string `json:"title"`
|
|
Template string `json:"template"`
|
|
CreatedAt string `json:"createdAt"`
|
|
}
|
|
|
|
func maskSecret(v string) string {
|
|
v = strings.TrimSpace(v)
|
|
if len(v) <= 4 {
|
|
return "••••••"
|
|
}
|
|
return "••••" + v[len(v)-4:]
|
|
}
|
|
|
|
func (s *Store) ListSecrets(owner string) ([]Secret, error) {
|
|
rows, err := s.db.Query(s.q(`SELECT id,service,label,value,active,created_at FROM secret_keys WHERE owner=? ORDER BY service, created_at`), owner)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []Secret{}
|
|
for rows.Next() {
|
|
var sec Secret
|
|
var active int
|
|
if err := rows.Scan(&sec.ID, &sec.Service, &sec.Label, &sec.Value, &active, &sec.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
sec.Active = active == 1
|
|
sec.Mask = maskSecret(sec.Value)
|
|
sec.Value = ""
|
|
out = append(out, sec)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// ActiveSecret 返回该服务当前生效的明文。没有记录时 ok 为 false。
|
|
func (s *Store) ActiveSecret(owner, service string) (string, bool) {
|
|
var v string
|
|
err := s.db.QueryRow(s.q(`SELECT value FROM secret_keys WHERE owner=? AND service=? AND active=1 ORDER BY created_at DESC LIMIT 1`), owner, service).Scan(&v)
|
|
if err != nil || strings.TrimSpace(v) == "" {
|
|
return "", false
|
|
}
|
|
return v, true
|
|
}
|
|
|
|
// AddSecret 追加一把密钥并把它设为该服务唯一生效项。
|
|
func (s *Store) AddSecret(owner, service, label, value string) (Secret, error) {
|
|
service = strings.TrimSpace(service)
|
|
value = strings.TrimSpace(value)
|
|
label = strings.TrimSpace(label)
|
|
if service == "" || value == "" {
|
|
return Secret{}, fmt.Errorf("store: secret service and value are required")
|
|
}
|
|
if label == "" {
|
|
label = "未命名"
|
|
}
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return Secret{}, err
|
|
}
|
|
defer tx.Rollback()
|
|
if _, err := tx.Exec(s.q(`UPDATE secret_keys SET active=0 WHERE owner=? AND service=?`), owner, service); err != nil {
|
|
return Secret{}, err
|
|
}
|
|
sec := Secret{ID: newID("key"), Service: service, Label: label, Active: true, CreatedAt: nowISO(), Mask: maskSecret(value)}
|
|
if _, err := tx.Exec(s.q(`INSERT INTO secret_keys(id,owner,service,label,value,active,created_at) VALUES(?,?,?,?,?,?,?)`),
|
|
sec.ID, owner, service, label, value, 1, sec.CreatedAt); err != nil {
|
|
return Secret{}, err
|
|
}
|
|
return sec, tx.Commit()
|
|
}
|
|
|
|
func (s *Store) ActivateSecret(owner, id string) error {
|
|
var service string
|
|
err := s.db.QueryRow(s.q(`SELECT service FROM secret_keys WHERE id=? AND owner=?`), id, owner).Scan(&service)
|
|
if err != nil {
|
|
return ErrNotFound
|
|
}
|
|
tx, err := s.db.Begin()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
if _, err := tx.Exec(s.q(`UPDATE secret_keys SET active=0 WHERE owner=? AND service=?`), owner, service); err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec(s.q(`UPDATE secret_keys SET active=1 WHERE id=? AND owner=?`), id, owner); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
func (s *Store) RenameSecret(owner, id, label string) error {
|
|
label = strings.TrimSpace(label)
|
|
if label == "" {
|
|
return fmt.Errorf("store: label required")
|
|
}
|
|
res, err := s.db.Exec(s.q(`UPDATE secret_keys SET label=? WHERE id=? AND owner=?`), label, id, owner)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) DeleteSecret(owner, id string) error {
|
|
res, err := s.db.Exec(s.q(`DELETE FROM secret_keys WHERE id=? AND owner=?`), id, owner)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AddSecretAccountChecked fences the final secret write against account disable.
|
|
func (s *Store) AddSecretAccountChecked(actor, service, label, value string, allowImplicitDefault bool) (Secret, error) {
|
|
service, label, value = strings.TrimSpace(service), strings.TrimSpace(label), strings.TrimSpace(value)
|
|
if service == "" || value == "" {
|
|
return Secret{}, fmt.Errorf("store: secret service and value are required")
|
|
}
|
|
if label == "" {
|
|
label = "未命名"
|
|
}
|
|
sec := Secret{ID: newID("key"), Service: service, Label: label, Active: true, CreatedAt: nowISO(), Mask: maskSecret(value)}
|
|
err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
|
if _, err := tx.Exec(s.q(`UPDATE secret_keys SET active=0 WHERE owner=? AND service=?`), actor, service); err != nil {
|
|
return err
|
|
}
|
|
_, err := tx.Exec(s.q(`INSERT INTO secret_keys(id,owner,service,label,value,active,created_at) VALUES(?,?,?,?,?,?,?)`),
|
|
sec.ID, actor, service, label, value, 1, sec.CreatedAt)
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return Secret{}, err
|
|
}
|
|
return sec, nil
|
|
}
|
|
|
|
func (s *Store) ActivateSecretAccountChecked(actor, id string, allowImplicitDefault bool) error {
|
|
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
|
var service string
|
|
err := tx.QueryRow(s.q(`SELECT service FROM secret_keys WHERE id=? AND owner=?`), id, actor).Scan(&service)
|
|
if err == sql.ErrNoRows {
|
|
return ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := tx.Exec(s.q(`UPDATE secret_keys SET active=0 WHERE owner=? AND service=?`), actor, service); err != nil {
|
|
return err
|
|
}
|
|
res, err := tx.Exec(s.q(`UPDATE secret_keys SET active=1 WHERE id=? AND owner=?`), id, actor)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, err := res.RowsAffected(); err != nil {
|
|
return err
|
|
} else if n != 1 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *Store) RenameSecretAccountChecked(actor, id, label string, allowImplicitDefault bool) error {
|
|
label = strings.TrimSpace(label)
|
|
if label == "" {
|
|
return fmt.Errorf("store: label required")
|
|
}
|
|
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
|
res, err := tx.Exec(s.q(`UPDATE secret_keys SET label=? WHERE id=? AND owner=?`), label, id, actor)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, err := res.RowsAffected(); err != nil {
|
|
return err
|
|
} else if n != 1 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *Store) DeleteSecretAccountChecked(actor, id string, allowImplicitDefault bool) error {
|
|
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
|
res, err := tx.Exec(s.q(`DELETE FROM secret_keys WHERE id=? AND owner=?`), id, actor)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, err := res.RowsAffected(); err != nil {
|
|
return err
|
|
} else if n != 1 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *Store) HasSecret(owner, service string) (bool, error) {
|
|
var n int
|
|
err := s.db.QueryRow(s.q(`SELECT COUNT(1) FROM secret_keys WHERE owner=? AND service=?`), owner, service).Scan(&n)
|
|
return n > 0, err
|
|
}
|
|
|
|
// SaveSnapshotIfChanged 在正文与最近一份不同时留档。相同则跳过。
|
|
func (s *Store) SaveSnapshotIfChanged(owner, scope, body, reason string) (bool, error) {
|
|
var prev string
|
|
err := s.db.QueryRow(s.q(`SELECT body FROM settings_snapshots WHERE owner=? AND scope=? ORDER BY created_at DESC LIMIT 1`), owner, scope).Scan(&prev)
|
|
if err == nil && prev == body {
|
|
return false, nil
|
|
}
|
|
if err != nil && err != sql.ErrNoRows {
|
|
return false, err
|
|
}
|
|
_, err = s.db.Exec(s.q(`INSERT INTO settings_snapshots(id,owner,scope,body,reason,created_at) VALUES(?,?,?,?,?,?)`),
|
|
newID("snap"), owner, scope, body, reason, nowISO())
|
|
return err == nil, err
|
|
}
|
|
|
|
// SaveSnapshotIfChangedAccountChecked fences the optional post-merge snapshot
|
|
// in its own final transaction. A disable between the settings commit and this
|
|
// transaction may suppress the snapshot, but cannot admit a late write.
|
|
func (s *Store) SaveSnapshotIfChangedAccountChecked(actor, owner, scope, body, reason string, allowImplicitDefault bool) (bool, error) {
|
|
var saved bool
|
|
err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, account Account) error {
|
|
if err := s.dialect.lockScope(tx, owner, scope); err != nil {
|
|
return err
|
|
}
|
|
if err := s.checkSettingsScopeWriteActor(tx, actor, owner, scope, account, allowImplicitDefault); err != nil {
|
|
return err
|
|
}
|
|
var prev string
|
|
err := tx.QueryRow(s.q(`SELECT body FROM settings_snapshots WHERE owner=? AND scope=? ORDER BY created_at DESC LIMIT 1`), owner, scope).Scan(&prev)
|
|
if err == nil && prev == body {
|
|
return nil
|
|
}
|
|
if err != nil && err != sql.ErrNoRows {
|
|
return err
|
|
}
|
|
_, err = tx.Exec(s.q(`INSERT INTO settings_snapshots(id,owner,scope,body,reason,created_at) VALUES(?,?,?,?,?,?)`),
|
|
newID("snap"), owner, scope, body, reason, nowISO())
|
|
if err == nil {
|
|
saved = true
|
|
}
|
|
return err
|
|
})
|
|
return saved && err == nil, err
|
|
}
|
|
|
|
// LatestSnapshotAge 返回最近一份快照的时间。没有快照时 ok 为 false。
|
|
func (s *Store) LatestSnapshotAt(owner, scope string) (time.Time, bool) {
|
|
var raw string
|
|
err := s.db.QueryRow(s.q(`SELECT created_at FROM settings_snapshots WHERE owner=? AND scope=? ORDER BY created_at DESC LIMIT 1`), owner, scope).Scan(&raw)
|
|
if err != nil {
|
|
return time.Time{}, false
|
|
}
|
|
t, err := time.Parse(time.RFC3339Nano, raw)
|
|
if err != nil {
|
|
return time.Time{}, false
|
|
}
|
|
return t, true
|
|
}
|
|
|
|
func (s *Store) SetSessionNote(owner, id, note string) error {
|
|
res, err := s.db.Exec(s.q(`UPDATE conversations SET session_note=?, updated_at=? WHERE id=? AND owner=?`), note, nowISO(), id, owner)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) SetMessageFlags(id string, excluded bool, group string, index int) error {
|
|
ex := 0
|
|
if excluded {
|
|
ex = 1
|
|
}
|
|
_, err := s.db.Exec(s.q(`UPDATE messages SET excluded=?, variant_group=?, variant_index=? WHERE id=?`), ex, group, index, id)
|
|
return err
|
|
}
|
|
|
|
// ExcludeRunGroup 把这条消息以及同一 run 的消息一起标成排除或恢复。
|
|
// 工具调用和结果落在同一条助手消息里,同一 run 的其它落库消息一并处理。
|
|
func (s *Store) ExcludeRunGroup(id string, excluded bool) error {
|
|
var runID string
|
|
err := s.db.QueryRow(s.q(`SELECT run_id FROM messages WHERE id=?`), id).Scan(&runID)
|
|
if err != nil {
|
|
return ErrNotFound
|
|
}
|
|
ex := 0
|
|
if excluded {
|
|
ex = 1
|
|
}
|
|
if _, err := s.db.Exec(s.q(`UPDATE messages SET excluded=? WHERE id=?`), ex, id); err != nil {
|
|
return err
|
|
}
|
|
if runID == "" {
|
|
return nil
|
|
}
|
|
_, err = s.db.Exec(s.q(`UPDATE messages SET excluded=? WHERE run_id=?`), ex, runID)
|
|
return err
|
|
}
|
|
|
|
// SelectVariant 让同一组里只有这条候选进入上下文。
|
|
func (s *Store) SelectVariant(id string) error {
|
|
var group, conv string
|
|
err := s.db.QueryRow(s.q(`SELECT variant_group, conversation_id FROM messages WHERE id=?`), id).Scan(&group, &conv)
|
|
if err != nil {
|
|
return ErrNotFound
|
|
}
|
|
if group == "" {
|
|
return s.ExcludeRunGroup(id, false)
|
|
}
|
|
if _, err := s.db.Exec(s.q(`UPDATE messages SET excluded=1 WHERE conversation_id=? AND variant_group=?`), conv, group); err != nil {
|
|
return err
|
|
}
|
|
_, err = s.db.Exec(s.q(`UPDATE messages SET excluded=0 WHERE id=?`), id)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) SetRegenPending(conversationID, group string, index int) error {
|
|
res, err := s.db.Exec(s.q(`UPDATE regen_pending SET variant_group=?, variant_index=? WHERE conversation_id=?`), group, index, conversationID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n > 0 {
|
|
return nil
|
|
}
|
|
_, err = s.db.Exec(s.q(`INSERT INTO regen_pending(conversation_id, variant_group, variant_index) VALUES(?,?,?)`), conversationID, group, index)
|
|
return err
|
|
}
|
|
|
|
func (s *Store) TakeRegenPending(conversationID string) (group string, index int, ok bool) {
|
|
err := s.db.QueryRow(s.q(`SELECT variant_group, variant_index FROM regen_pending WHERE conversation_id=?`), conversationID).Scan(&group, &index)
|
|
if err != nil {
|
|
return "", 0, false
|
|
}
|
|
_, _ = s.db.Exec(s.q(`DELETE FROM regen_pending WHERE conversation_id=?`), conversationID)
|
|
return group, index, true
|
|
}
|
|
|
|
// PeekRegenPending leaves the marker in place until settlement commits its
|
|
// assistant message, avoiding a disable/fence between consumption and settle.
|
|
func (s *Store) PeekRegenPending(conversationID string) (group string, index int, ok bool) {
|
|
err := s.db.QueryRow(s.q(`SELECT variant_group, variant_index FROM regen_pending WHERE conversation_id=?`), conversationID).Scan(&group, &index)
|
|
return group, index, err == nil
|
|
}
|
|
|
|
func (s *Store) ListContextEntries(owner, projectID, conversationID string) ([]ContextEntry, error) {
|
|
rows, err := s.db.Query(s.q(`SELECT id,project_id,conversation_id,keys_json,secondary,logic,content,constant,scan_depth,sort_order,budget_chars,enabled
|
|
FROM context_entries WHERE owner=? ORDER BY sort_order, created_at`), owner)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []ContextEntry{}
|
|
for rows.Next() {
|
|
e, err := scanEntry(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if e.ProjectID != "" && projectID != "" && e.ProjectID != projectID {
|
|
continue
|
|
}
|
|
if e.ConversationID != "" && conversationID != "" && e.ConversationID != conversationID {
|
|
continue
|
|
}
|
|
out = append(out, e)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func scanEntry(rows interface{ Scan(...any) error }) (ContextEntry, error) {
|
|
var e ContextEntry
|
|
var keys, logic string
|
|
var constant, enabled int
|
|
err := rows.Scan(&e.ID, &e.ProjectID, &e.ConversationID, &keys, &e.Secondary, &logic, &e.Content, &constant, &e.ScanDepth, &e.SortOrder, &e.BudgetChars, &enabled)
|
|
if err != nil {
|
|
return e, err
|
|
}
|
|
_ = json.Unmarshal([]byte(keys), &e.Keys)
|
|
if e.Keys == nil {
|
|
e.Keys = []string{}
|
|
}
|
|
e.Logic = logic
|
|
if e.Logic == "" {
|
|
e.Logic = "and"
|
|
}
|
|
e.Constant = constant == 1
|
|
e.Enabled = enabled == 1
|
|
return e, nil
|
|
}
|
|
|
|
func (s *Store) SaveContextEntry(owner string, e ContextEntry) (ContextEntry, error) {
|
|
if strings.TrimSpace(e.Content) == "" {
|
|
return ContextEntry{}, fmt.Errorf("store: entry content required")
|
|
}
|
|
if e.Logic != "and" && e.Logic != "or" && e.Logic != "not" {
|
|
e.Logic = "and"
|
|
}
|
|
if e.ScanDepth <= 0 {
|
|
e.ScanDepth = 4
|
|
}
|
|
if e.BudgetChars <= 0 {
|
|
e.BudgetChars = 2000
|
|
}
|
|
raw, _ := json.Marshal(e.Keys)
|
|
constant, enabled := 0, 0
|
|
if e.Constant {
|
|
constant = 1
|
|
}
|
|
if e.Enabled || e.ID == "" {
|
|
enabled = 1
|
|
e.Enabled = true
|
|
}
|
|
if e.ID == "" {
|
|
e.ID = newID("ctx")
|
|
_, err := s.db.Exec(s.q(`INSERT INTO context_entries(id,owner,project_id,conversation_id,keys_json,secondary,logic,content,constant,scan_depth,sort_order,budget_chars,enabled,created_at)
|
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`),
|
|
e.ID, owner, e.ProjectID, e.ConversationID, string(raw), e.Secondary, e.Logic, e.Content, constant, e.ScanDepth, e.SortOrder, e.BudgetChars, enabled, nowISO())
|
|
return e, err
|
|
}
|
|
res, err := s.db.Exec(s.q(`UPDATE context_entries SET project_id=?, conversation_id=?, keys_json=?, secondary=?, logic=?, content=?, constant=?, scan_depth=?, sort_order=?, budget_chars=?, enabled=? WHERE id=? AND owner=?`),
|
|
e.ProjectID, e.ConversationID, string(raw), e.Secondary, e.Logic, e.Content, constant, e.ScanDepth, e.SortOrder, e.BudgetChars, enabled, e.ID, owner)
|
|
if err != nil {
|
|
return e, err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return e, ErrNotFound
|
|
}
|
|
return e, nil
|
|
}
|
|
|
|
func (s *Store) DeleteContextEntry(owner, id string) error {
|
|
res, err := s.db.Exec(s.q(`DELETE FROM context_entries WHERE id=? AND owner=?`), id, owner)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) SaveContextEntryAccountChecked(actor string, e ContextEntry, allowImplicitDefault bool) (ContextEntry, error) {
|
|
if strings.TrimSpace(e.Content) == "" {
|
|
return ContextEntry{}, fmt.Errorf("store: entry content required")
|
|
}
|
|
if e.Logic != "and" && e.Logic != "or" && e.Logic != "not" {
|
|
e.Logic = "and"
|
|
}
|
|
if e.ScanDepth <= 0 {
|
|
e.ScanDepth = 4
|
|
}
|
|
if e.BudgetChars <= 0 {
|
|
e.BudgetChars = 2000
|
|
}
|
|
raw, _ := json.Marshal(e.Keys)
|
|
constant, enabled := 0, 0
|
|
if e.Constant {
|
|
constant = 1
|
|
}
|
|
if e.Enabled || e.ID == "" {
|
|
enabled = 1
|
|
e.Enabled = true
|
|
}
|
|
creating := e.ID == ""
|
|
if creating {
|
|
e.ID = newID("ctx")
|
|
}
|
|
err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
|
if creating {
|
|
_, err := tx.Exec(s.q(`INSERT INTO context_entries(id,owner,project_id,conversation_id,keys_json,secondary,logic,content,constant,scan_depth,sort_order,budget_chars,enabled,created_at)
|
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`),
|
|
e.ID, actor, e.ProjectID, e.ConversationID, string(raw), e.Secondary, e.Logic, e.Content, constant, e.ScanDepth, e.SortOrder, e.BudgetChars, enabled, nowISO())
|
|
return err
|
|
}
|
|
res, err := tx.Exec(s.q(`UPDATE context_entries SET project_id=?, conversation_id=?, keys_json=?, secondary=?, logic=?, content=?, constant=?, scan_depth=?, sort_order=?, budget_chars=?, enabled=? WHERE id=? AND owner=?`),
|
|
e.ProjectID, e.ConversationID, string(raw), e.Secondary, e.Logic, e.Content, constant, e.ScanDepth, e.SortOrder, e.BudgetChars, enabled, e.ID, actor)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, err := res.RowsAffected(); err != nil {
|
|
return err
|
|
} else if n != 1 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return ContextEntry{}, err
|
|
}
|
|
return e, nil
|
|
}
|
|
|
|
func (s *Store) DeleteContextEntryAccountChecked(actor, id string, allowImplicitDefault bool) error {
|
|
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
|
res, err := tx.Exec(s.q(`DELETE FROM context_entries WHERE id=? AND owner=?`), id, actor)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, err := res.RowsAffected(); err != nil {
|
|
return err
|
|
} else if n != 1 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *Store) ListUserCommands(owner string) ([]UserCommand, error) {
|
|
rows, err := s.db.Query(s.q(`SELECT id,name,title,template,created_at FROM user_commands WHERE owner=? ORDER BY name`), owner)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []UserCommand{}
|
|
for rows.Next() {
|
|
var c UserCommand
|
|
if err := rows.Scan(&c.ID, &c.Name, &c.Title, &c.Template, &c.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, c)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func (s *Store) SaveUserCommand(owner string, c UserCommand) (UserCommand, error) {
|
|
c.Name = strings.TrimSpace(c.Name)
|
|
if !strings.HasPrefix(c.Name, "/") {
|
|
c.Name = "/" + c.Name
|
|
}
|
|
if c.Title == "" {
|
|
c.Title = c.Name
|
|
}
|
|
if strings.TrimSpace(c.Template) == "" {
|
|
return UserCommand{}, fmt.Errorf("store: command template required")
|
|
}
|
|
if c.ID == "" {
|
|
c.ID = newID("cmd")
|
|
c.CreatedAt = nowISO()
|
|
_, err := s.db.Exec(s.q(`INSERT INTO user_commands(id,owner,name,title,template,created_at) VALUES(?,?,?,?,?,?)`),
|
|
c.ID, owner, c.Name, c.Title, c.Template, c.CreatedAt)
|
|
return c, err
|
|
}
|
|
res, err := s.db.Exec(s.q(`UPDATE user_commands SET name=?, title=?, template=? WHERE id=? AND owner=?`), c.Name, c.Title, c.Template, c.ID, owner)
|
|
if err != nil {
|
|
return c, err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return c, ErrNotFound
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
func (s *Store) DeleteUserCommand(owner, id string) error {
|
|
res, err := s.db.Exec(s.q(`DELETE FROM user_commands WHERE id=? AND owner=?`), id, owner)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) SaveUserCommandAccountChecked(actor string, c UserCommand, allowImplicitDefault bool) (UserCommand, error) {
|
|
c.Name = strings.TrimSpace(c.Name)
|
|
if !strings.HasPrefix(c.Name, "/") {
|
|
c.Name = "/" + c.Name
|
|
}
|
|
if c.Title == "" {
|
|
c.Title = c.Name
|
|
}
|
|
if strings.TrimSpace(c.Template) == "" {
|
|
return UserCommand{}, fmt.Errorf("store: command template required")
|
|
}
|
|
creating := c.ID == ""
|
|
if creating {
|
|
c.ID = newID("cmd")
|
|
c.CreatedAt = nowISO()
|
|
}
|
|
err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
|
if creating {
|
|
_, err := tx.Exec(s.q(`INSERT INTO user_commands(id,owner,name,title,template,created_at) VALUES(?,?,?,?,?,?)`),
|
|
c.ID, actor, c.Name, c.Title, c.Template, c.CreatedAt)
|
|
return err
|
|
}
|
|
res, err := tx.Exec(s.q(`UPDATE user_commands SET name=?, title=?, template=? WHERE id=? AND owner=?`), c.Name, c.Title, c.Template, c.ID, actor)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, err := res.RowsAffected(); err != nil {
|
|
return err
|
|
} else if n != 1 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return UserCommand{}, err
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// DeleteUserCommandAccountChecked returns the deleted name from the guarded
|
|
// transaction so the caller can unregister that exact command from its Bus.
|
|
func (s *Store) DeleteUserCommandAccountChecked(actor, id string, allowImplicitDefault bool) (UserCommand, error) {
|
|
var deleted UserCommand
|
|
err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
|
query := s.q(`SELECT id,name,title,template,created_at FROM user_commands WHERE id=? AND owner=?`)
|
|
if s.Dialect() == "postgres" {
|
|
query += ` FOR UPDATE`
|
|
}
|
|
err := tx.QueryRow(query, id, actor).Scan(&deleted.ID, &deleted.Name, &deleted.Title, &deleted.Template, &deleted.CreatedAt)
|
|
if err == sql.ErrNoRows {
|
|
return ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
res, err := tx.Exec(s.q(`DELETE FROM user_commands WHERE id=? AND owner=?`), id, actor)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, err := res.RowsAffected(); err != nil {
|
|
return err
|
|
} else if n != 1 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return UserCommand{}, err
|
|
}
|
|
return deleted, nil
|
|
}
|
|
|
|
// CheckEffectiveAccountForExternalEffect is a final state check immediately
|
|
// before a Bus mutation. The Bus is outside the account database transaction:
|
|
// disable can still commit between this check and the in-memory effect.
|
|
func (s *Store) CheckEffectiveAccountForExternalEffect(actor string, allowImplicitDefault bool) error {
|
|
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(*sql.Tx, Account) error { return nil })
|
|
}
|
|
|
|
// MessageByID 读取一条消息。不存在时返回 ErrNotFound。
|
|
func (s *Store) MessageByID(id string) (core.Message, error) {
|
|
var m core.Message
|
|
var parts, ca string
|
|
var excluded, index int
|
|
err := s.db.QueryRow(s.q(`SELECT id,conversation_id,run_id,role,parts,created_at,excluded,variant_group,variant_index FROM messages WHERE id=?`), id).
|
|
Scan(&m.ID, &m.ConversationID, &m.RunID, &m.Role, &parts, &ca, &excluded, &m.VariantGroup, &index)
|
|
if err != nil {
|
|
return m, ErrNotFound
|
|
}
|
|
m.Role = core.Role(m.Role)
|
|
m.Parts, _ = unmarshalJSON[[]core.Part](parts)
|
|
m.CreatedAt, _ = parseTime(ca)
|
|
m.Excluded = excluded == 1
|
|
m.VariantIndex = index
|
|
return m, nil
|
|
}
|