Files
Agent-System-Backend/internal/store/cards_account_write.go
T

245 lines
8.8 KiB
Go

package store
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
core "agent-system-core"
)
type storedCardLink struct {
owner, projectID, conversationID, ref, payload string
version int
}
func (s *Store) cardLinkForWrite(tx *sql.Tx, id string, lock bool) (storedCardLink, bool, error) {
query := s.q(`SELECT owner,project_id,conversation_id,ref,payload,version FROM activity_cards WHERE id=?`)
if lock && s.Dialect() == "postgres" {
query += ` FOR UPDATE`
}
var link storedCardLink
err := tx.QueryRow(query, id).Scan(&link.owner, &link.projectID, &link.conversationID, &link.ref, &link.payload, &link.version)
if errors.Is(err, sql.ErrNoRows) {
return storedCardLink{}, false, nil
}
return link, err == nil, err
}
// cardResourcesForWrite verifies the stored links after the actor lock and
// before taking the card row lock. A member can own a private conversation
// inside another creator's shared project; project ownership is a separate
// authorization decision made by the calling domain service.
func (s *Store) cardResourcesForWrite(tx *sql.Tx, actor, projectID, conversationID string) error {
if conversationID != "" && projectID == "" {
return ErrNotFound
}
if projectID != "" {
if conversationID == "" {
if _, err := s.projectForAccountWrite(tx, actor, projectID); err != nil {
return err
}
} else {
query := s.q(`SELECT id FROM projects WHERE id=?`)
if s.Dialect() == "postgres" {
query += ` FOR UPDATE`
}
var found string
if err := tx.QueryRow(query, projectID).Scan(&found); errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
} else if err != nil {
return err
}
}
}
if conversationID != "" {
query := s.q(`SELECT owner,project_id FROM conversations WHERE id=?`)
if s.Dialect() == "postgres" {
query += ` FOR UPDATE`
}
var owner, project string
err := tx.QueryRow(query, conversationID).Scan(&owner, &project)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
if owner != actor || project != projectID {
return ErrNotFound
}
}
return nil
}
func (s *Store) saveCardAccountChecked(actor string, card *core.ActivityCard, ref string, expected *int, allowImplicitDefault bool) error {
if card == nil || card.ID == "" {
return fmt.Errorf("save card: id and card required")
}
payload, err := json.Marshal(card)
if err != nil {
return fmt.Errorf("save card %s: %w", card.ID, err)
}
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
// Read immutable links first, then acquire project, conversation and card
// row locks in that order. Recheck links after the row lock.
first, exists, err := s.cardLinkForWrite(tx, card.ID, false)
if err != nil {
return err
}
if exists && (first.owner != actor || first.projectID != card.ProjectID || first.conversationID != card.ConversationID) {
return ErrNotFound
}
if err := s.cardResourcesForWrite(tx, actor, card.ProjectID, card.ConversationID); err != nil {
return err
}
current, stillExists, err := s.cardLinkForWrite(tx, card.ID, true)
if err != nil {
return err
}
if stillExists != exists || (exists && (current.owner != actor || current.projectID != card.ProjectID || current.conversationID != card.ConversationID)) {
return ErrConflict
}
if expected != nil && current.version != *expected {
return ErrConflict
}
if !exists {
res, err := tx.Exec(s.q(`INSERT INTO activity_cards(id,project_id,conversation_id,ref,owner,payload,status,pinned,created_at,updated_at,version)
VALUES(?,?,?,?,?,?,?,?,?,?,1) ON CONFLICT(id) DO NOTHING`), card.ID, card.ProjectID, card.ConversationID, ref, actor,
string(payload), string(card.Status), boolInt(card.Pinned), card.CreatedAt.Format(time.RFC3339Nano), card.UpdatedAt.Format(time.RFC3339Nano))
if err != nil {
return err
}
if n, err := res.RowsAffected(); err != nil {
return err
} else if n != 1 {
return ErrConflict
}
return nil
}
if ref == "" {
ref = current.ref
}
res, err := tx.Exec(s.q(`UPDATE activity_cards SET payload=?,status=?,pinned=?,ref=?,updated_at=?,version=version+1 WHERE id=? AND owner=? AND version=?`),
string(payload), string(card.Status), boolInt(card.Pinned), ref, card.UpdatedAt.Format(time.RFC3339Nano), card.ID, actor, current.version)
if err != nil {
return err
}
if n, err := res.RowsAffected(); err != nil {
return err
} else if n != 1 {
return ErrConflict
}
return nil
})
}
// SaveCardAccountChecked is the guarded counterpart of the legacy fixture API.
// The actor lock and effective-state check cover the final card mutation.
func (s *Store) SaveCardAccountChecked(actor string, card *core.ActivityCard, ref string, allowImplicitDefault bool) error {
return s.saveCardAccountChecked(actor, card, ref, nil, allowImplicitDefault)
}
// SaveCardVersionedAccountChecked also enforces the stored card version.
func (s *Store) SaveCardVersionedAccountChecked(actor string, card *core.ActivityCard, ref string, expected int, allowImplicitDefault bool) error {
return s.saveCardAccountChecked(actor, card, ref, &expected, allowImplicitDefault)
}
// ArchiveCardToProjectAccountChecked commits the project copy and origin
// dismissal together. The return flag is false on an idempotent retry.
func (s *Store) ArchiveCardToProjectAccountChecked(actor, id string, now time.Time, allowImplicitDefault bool) (core.ActivityCard, core.ActivityCard, bool, error) {
var archived, origin core.ActivityCard
created := false
err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
first, exists, err := s.cardLinkForWrite(tx, id, false)
if err != nil {
return err
}
if !exists || first.owner != actor {
return ErrNotFound
}
if first.conversationID == "" {
return errors.New("card: already project-level")
}
if err := s.cardResourcesForWrite(tx, actor, first.projectID, first.conversationID); err != nil {
return err
}
current, exists, err := s.cardLinkForWrite(tx, id, true)
if err != nil {
return err
}
if !exists || current.owner != actor || current.projectID != first.projectID || current.conversationID != first.conversationID {
return ErrNotFound
}
if strings.HasPrefix(current.ref, "hitl:") || strings.HasPrefix(current.ref, "cmd:") {
return errors.New("card: flow-bound card cannot be archived to project")
}
if err := json.Unmarshal([]byte(current.payload), &origin); err != nil {
return fmt.Errorf("card %s payload: %w", id, err)
}
if origin.ID != id || origin.ProjectID != current.projectID || origin.ConversationID != current.conversationID {
return ErrNotFound
}
if origin.Task != nil {
return errors.New("card: keep the original task reference in its conversation")
}
ref := "to_project:" + id
var prior string
err = tx.QueryRow(s.q(`SELECT id FROM activity_cards WHERE owner=? AND ref=? ORDER BY updated_at DESC LIMIT 1`), actor, ref).Scan(&prior)
if err == nil {
link, ok, err := s.cardLinkForWrite(tx, prior, true)
if err != nil {
return err
}
if !ok || link.owner != actor || link.projectID != current.projectID || link.conversationID != "" {
return ErrNotFound
}
return json.Unmarshal([]byte(link.payload), &archived)
}
if !errors.Is(err, sql.ErrNoRows) {
return err
}
archived = origin
archived.ID = NewID("card")
archived.ConversationID = ""
archived.AnchorMessageID = core.ID(id)
archived.Pinned = false
archived.CreatedAt, archived.UpdatedAt = now, now
archived.Body = append(append([]core.UINode{}, origin.Body...), core.UINode{Type: core.UIKV, KV: [][2]string{{"留档", "自会话卡 " + id}}})
payload, err := json.Marshal(&archived)
if err != nil {
return err
}
if _, err := tx.Exec(s.q(`INSERT INTO activity_cards(id,project_id,conversation_id,ref,owner,payload,status,pinned,created_at,updated_at,version)
VALUES(?,?,?,?,?,?,?,?,?,?,1)`), archived.ID, archived.ProjectID, "", ref, actor, string(payload), string(archived.Status), boolInt(archived.Pinned), now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)); err != nil {
return err
}
origin.Status = core.CardDismissed
origin.Tone = "info"
origin.UpdatedAt = now
payload, err = json.Marshal(&origin)
if err != nil {
return err
}
res, err := tx.Exec(s.q(`UPDATE activity_cards SET payload=?,status=?,updated_at=?,version=version+1 WHERE id=? AND owner=? AND version=?`),
string(payload), string(origin.Status), now.Format(time.RFC3339Nano), id, actor, current.version)
if err != nil {
return err
}
if n, err := res.RowsAffected(); err != nil {
return err
} else if n != 1 {
return ErrConflict
}
created = true
return nil
})
if err != nil {
return core.ActivityCard{}, core.ActivityCard{}, false, err
}
return archived, origin, created, nil
}