Files
Agent-System-Backend/internal/store/regeneration_account_write.go
T
weichen adcd8fefd2 Fence U3 account writes and stabilize directory cursors
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.
2026-10-14 07:33:54 +00:00

121 lines
4.2 KiB
Go

package store
import (
"database/sql"
"errors"
core "agent-system-core"
)
// PrepareRegenerationAccountChecked excludes the selected assistant run and
// records the next variant in one final actor-checked transaction. The target
// and conversation owner are read again after the account and conversation
// locks, so an earlier HTTP lookup cannot authorize a late write.
func (s *Store) PrepareRegenerationAccountChecked(actor, conversationID, messageID string, allowImplicitDefault bool) (string, int, error) {
var group string
var nextIndex int
err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
if err := s.dialect.lockConversation(tx, conversationID); err != nil {
return err
}
query := s.q(`SELECT COALESCE(m.run_id,''),m.variant_group,m.variant_index,m.role,c.owner
FROM messages m JOIN conversations c ON c.id=m.conversation_id
WHERE m.id=? AND m.conversation_id=?`)
if s.Dialect() == "postgres" {
query += ` FOR UPDATE OF m`
}
var runID, existingGroup, role, owner string
var variantIndex int
if err := tx.QueryRow(query, messageID, conversationID).Scan(&runID, &existingGroup, &variantIndex, &role, &owner); errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
} else if err != nil {
return err
}
if owner != actor || role != string(core.RoleAssistant) {
return ErrNotFound
}
maxIndex := variantIndex
if existingGroup != "" {
if err := tx.QueryRow(s.q(`SELECT COALESCE(MAX(variant_index),0) FROM messages WHERE conversation_id=? AND variant_group=?`),
conversationID, existingGroup).Scan(&maxIndex); err != nil {
return err
}
if variantIndex > maxIndex {
maxIndex = variantIndex
}
}
group = existingGroup
if group == "" {
group = "var_" + messageID
}
var pendingGroup string
var pendingIndex int
err := tx.QueryRow(s.q(`SELECT variant_group,variant_index FROM regen_pending WHERE conversation_id=?`), conversationID).Scan(&pendingGroup, &pendingIndex)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
if pendingGroup == group && pendingIndex > maxIndex {
maxIndex = pendingIndex
}
nextIndex = maxIndex + 1
if _, err := tx.Exec(s.q(`UPDATE messages SET excluded=1,variant_group=?,variant_index=? WHERE id=? AND conversation_id=?`),
group, variantIndex, messageID, conversationID); err != nil {
return err
}
if runID != "" {
if _, err := tx.Exec(s.q(`UPDATE messages SET excluded=1 WHERE run_id=? AND conversation_id=?`), runID, conversationID); err != nil {
return err
}
}
_, err = tx.Exec(s.q(`INSERT INTO regen_pending(conversation_id,variant_group,variant_index) VALUES(?,?,?)
ON CONFLICT(conversation_id) DO UPDATE SET variant_group=excluded.variant_group,variant_index=excluded.variant_index`),
conversationID, group, nextIndex)
return err
})
if err != nil {
return "", 0, err
}
return group, nextIndex, nil
}
// ClearRegenPendingAfterFailedStart is safety cleanup, not a new business
// write. It may remove this request's stale marker after account disable, but
// still locks the actor and verifies persisted conversation ownership. A later
// regeneration with another group or index is never cleared.
func (s *Store) ClearRegenPendingAfterFailedStart(actor, conversationID, group string, index int, allowImplicitDefault bool) error {
if actor == "" || conversationID == "" || group == "" || index <= 0 {
return ErrAccountForbidden
}
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
_, err = s.lockAccountUsers(tx, actor)
if errors.Is(err, ErrNotFound) && allowImplicitDefault && actor == OwnerDefault {
err = nil
}
if errors.Is(err, ErrNotFound) {
return ErrAccountForbidden
}
if err != nil {
return err
}
if err := s.dialect.lockConversation(tx, conversationID); err != nil {
return err
}
var owner string
if err := tx.QueryRow(s.q(`SELECT owner FROM conversations WHERE id=?`), conversationID).Scan(&owner); errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
} else if err != nil {
return err
}
if owner != actor {
return ErrNotFound
}
if _, err := tx.Exec(s.q(`DELETE FROM regen_pending WHERE conversation_id=? AND variant_group=? AND variant_index=?`), conversationID, group, index); err != nil {
return err
}
return tx.Commit()
}