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.
220 lines
7.3 KiB
Go
220 lines
7.3 KiB
Go
package cardsvc
|
|
|
|
import (
|
|
"agent-system-backend/internal/store"
|
|
core "agent-system-core"
|
|
"errors"
|
|
)
|
|
|
|
func (s *Service) requireAccountCheckedEmitter() error {
|
|
if s.emit != nil && s.accountCheckedEmit == nil {
|
|
return ErrGuardedEmitterRequired
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) ResolveByRefAccountChecked(actor, ref, actionID, answer string, allowImplicitDefault bool) error {
|
|
if err := s.requireAccountCheckedEmitter(); err != nil {
|
|
return err
|
|
}
|
|
id, err := s.st.CardIDByRef(actor, ref)
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
card, version, err := s.st.GetCardVersion(actor, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if card.Status.Terminal() {
|
|
return nil
|
|
}
|
|
card.Status = core.CardResolved
|
|
if err := s.UpdateVersionedAccountChecked(actor, card, version, allowImplicitDefault); errors.Is(err, store.ErrConflict) {
|
|
return nil
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
return s.emitCardAccountChecked(actor, card, core.EvCardResolved,
|
|
core.CardResolvedPayload{CardID: card.ID, ActionID: actionID, Answer: answer})
|
|
}
|
|
|
|
// AppendAccountChecked preserves stream-card throttling while fencing its
|
|
// durable body update against account disable.
|
|
func (s *Service) AppendAccountChecked(actor, id, text string, allowImplicitDefault bool) error {
|
|
if err := s.requireAccountCheckedEmitter(); err != nil {
|
|
return err
|
|
}
|
|
card, version, err := s.st.GetCardVersion(actor, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if text != "" {
|
|
if n := len(card.Body); n > 0 && card.Body[n-1].Type == core.UIMarkdown {
|
|
card.Body[n-1].Markdown += text
|
|
} else {
|
|
card.Body = append(card.Body, core.UINode{Type: core.UIMarkdown, Markdown: text})
|
|
}
|
|
}
|
|
card.UpdatedAt = s.now().UTC()
|
|
if err := s.st.SaveCardVersionedAccountChecked(actor, &card, "", version, allowImplicitDefault); err != nil {
|
|
return err
|
|
}
|
|
if card.ConversationID == "" && card.ProjectID == "" {
|
|
return nil
|
|
}
|
|
s.emitMu.Lock()
|
|
last, seen := s.lastEmit[id]
|
|
emit := !seen || s.now().Sub(last) >= cardAppendWindow
|
|
if emit {
|
|
s.lastEmit[id] = s.now()
|
|
}
|
|
s.emitMu.Unlock()
|
|
if !emit {
|
|
return nil
|
|
}
|
|
return s.emitCardAccountChecked(actor, card, core.EvCardUpdated, core.CardPayload{Card: card})
|
|
}
|
|
|
|
func (s *Service) emitCardAccountChecked(actor string, card core.ActivityCard, typ core.StreamEventType, payload any) error {
|
|
if s.accountCheckedEmit == nil || (card.ConversationID == "" && card.ProjectID == "") {
|
|
return nil
|
|
}
|
|
if card.ConversationID != "" {
|
|
if err := s.accountCheckedEmit(actor, card.ConversationID, card.SourceRunID, typ, payload); err != nil {
|
|
return err
|
|
}
|
|
if !s.MirrorConvCards {
|
|
return nil
|
|
}
|
|
}
|
|
if card.ProjectID != "" {
|
|
return s.accountCheckedEmit(actor, string(core.ProjectChannelKey(card.ProjectID)), card.SourceRunID, typ, payload)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CreateAccountChecked creates a card under the actor's final Store fence.
|
|
func (s *Service) CreateAccountChecked(actor string, card core.ActivityCard, ref string, allowImplicitDefault bool) (core.ActivityCard, error) {
|
|
if err := s.requireAccountCheckedEmitter(); err != nil {
|
|
return card, err
|
|
}
|
|
if card.ID == "" {
|
|
card.ID = store.NewID("card")
|
|
}
|
|
now := s.now().UTC()
|
|
card.CreatedAt, card.UpdatedAt = now, now
|
|
if card.Status == "" {
|
|
card.Status = core.CardActive
|
|
}
|
|
if err := s.st.SaveCardAccountChecked(actor, &card, ref, allowImplicitDefault); err != nil {
|
|
return card, err
|
|
}
|
|
return card, s.emitCardAccountChecked(actor, card, core.EvCardCreated, core.CardPayload{Card: card})
|
|
}
|
|
|
|
func (s *Service) updateVersionedAccountChecked(actor string, card core.ActivityCard, expectedVersion int, allowImplicitDefault bool) (core.ActivityCard, error) {
|
|
if err := s.requireAccountCheckedEmitter(); err != nil {
|
|
return card, err
|
|
}
|
|
card.UpdatedAt = s.now().UTC()
|
|
if err := s.st.SaveCardVersionedAccountChecked(actor, &card, "", expectedVersion, allowImplicitDefault); err != nil {
|
|
return card, err
|
|
}
|
|
return card, s.emitCardAccountChecked(actor, card, core.EvCardUpdated, core.CardPayload{Card: card})
|
|
}
|
|
|
|
func (s *Service) UpdateVersionedAccountChecked(actor string, card core.ActivityCard, expectedVersion int, allowImplicitDefault bool) error {
|
|
_, err := s.updateVersionedAccountChecked(actor, card, expectedVersion, allowImplicitDefault)
|
|
return err
|
|
}
|
|
|
|
func (s *Service) SetPinnedAccountChecked(actor, id string, pinned, allowImplicitDefault bool) (core.ActivityCard, error) {
|
|
card, version, err := s.st.GetCardVersion(actor, id)
|
|
if err != nil {
|
|
return card, err
|
|
}
|
|
card.Pinned = pinned
|
|
card, err = s.updateVersionedAccountChecked(actor, card, version, allowImplicitDefault)
|
|
if err != nil {
|
|
return card, err
|
|
}
|
|
return card, nil
|
|
}
|
|
|
|
func (s *Service) SetStatusAccountChecked(actor, id string, status core.CardStatus, tone, note string, allowImplicitDefault bool) (core.ActivityCard, error) {
|
|
card, version, err := s.st.GetCardVersion(actor, id)
|
|
if err != nil {
|
|
return card, err
|
|
}
|
|
card.Status = status
|
|
if tone != "" {
|
|
card.Tone = tone
|
|
}
|
|
card, err = s.updateVersionedAccountChecked(actor, card, version, allowImplicitDefault)
|
|
if err != nil {
|
|
return card, err
|
|
}
|
|
return card, s.emitCardAccountChecked(actor, card, core.EvCardStatus,
|
|
core.CardStatusPayload{CardID: card.ID, Status: status, Tone: card.Tone, Note: note})
|
|
}
|
|
|
|
func (s *Service) DismissAccountChecked(actor, id string, allowImplicitDefault bool) (core.ActivityCard, error) {
|
|
return s.SetStatusAccountChecked(actor, id, core.CardDismissed, "", "dismissed by user", allowImplicitDefault)
|
|
}
|
|
|
|
func (s *Service) ActionAccountChecked(actor, id, actionID, freeText string, allowImplicitDefault bool) (core.ActivityCard, string, error) {
|
|
card, version, err := s.st.GetCardVersion(actor, id)
|
|
if err != nil {
|
|
return card, "", err
|
|
}
|
|
if card.Status != core.CardActionRequired {
|
|
return card, "", ErrNotAwaiting
|
|
}
|
|
label, found := actionID, false
|
|
for _, action := range card.Actions {
|
|
if action.ID == actionID {
|
|
label, found = action.Label, true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
if actionID == "" && freeText != "" {
|
|
label = freeText
|
|
} else {
|
|
return card, "", ErrNoAction
|
|
}
|
|
}
|
|
card.Status = core.CardResolved
|
|
card, err = s.updateVersionedAccountChecked(actor, card, version, allowImplicitDefault)
|
|
if err != nil {
|
|
return card, "", err
|
|
}
|
|
err = s.emitCardAccountChecked(actor, card, core.EvCardResolved,
|
|
core.CardResolvedPayload{CardID: card.ID, ActionID: actionID, Answer: freeText})
|
|
return card, label, err
|
|
}
|
|
|
|
// ToProjectAccountChecked performs the copy and dismissal in one transaction.
|
|
// Events are published after commit through the actor-aware event sink.
|
|
func (s *Service) ToProjectAccountChecked(actor, id string, allowImplicitDefault bool) (core.ActivityCard, error) {
|
|
if err := s.requireAccountCheckedEmitter(); err != nil {
|
|
return core.ActivityCard{}, err
|
|
}
|
|
archived, origin, created, err := s.st.ArchiveCardToProjectAccountChecked(actor, id, s.now().UTC(), allowImplicitDefault)
|
|
if err != nil || !created {
|
|
return archived, err
|
|
}
|
|
if err := s.emitCardAccountChecked(actor, archived, core.EvCardCreated, core.CardPayload{Card: archived}); err != nil {
|
|
return archived, err
|
|
}
|
|
if err := s.emitCardAccountChecked(actor, origin, core.EvCardUpdated, core.CardPayload{Card: origin}); err != nil {
|
|
return archived, err
|
|
}
|
|
err = s.emitCardAccountChecked(actor, origin, core.EvCardStatus,
|
|
core.CardStatusPayload{CardID: origin.ID, Status: core.CardDismissed, Tone: origin.Tone, Note: "已留档到项目"})
|
|
return archived, err
|
|
}
|