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.
102 lines
3.9 KiB
Go
102 lines
3.9 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
|
|
core "agent-system-core"
|
|
)
|
|
|
|
// UpdateRunAccountChecked fences forward run state and completed output at the
|
|
// final run-row mutation. Terminal failure cleanup has a separate legacy path.
|
|
func (s *Store) UpdateRunAccountChecked(actor string, r *core.Run, allowImplicitDefault bool) error {
|
|
if r == nil || actor == "" || string(r.Owner) != actor {
|
|
return ErrAccountForbidden
|
|
}
|
|
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
|
var owner string
|
|
if err := tx.QueryRow(s.q(`SELECT owner FROM runs WHERE id=?`), r.ID).Scan(&owner); errors.Is(err, sql.ErrNoRows) {
|
|
return ErrNotFound
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
if owner != actor {
|
|
return ErrNotFound
|
|
}
|
|
var ended any
|
|
if r.EndedAt != nil {
|
|
ended = nowISO()
|
|
}
|
|
_, err := tx.Exec(s.q(`UPDATE runs SET status=?,phase=?,usage=?,error=?,ended_at=? WHERE id=? AND owner=?`),
|
|
string(r.Status), string(r.Phase), marshalJSON(r.Usage), marshalJSON(r.Error), ended, r.ID, actor)
|
|
return err
|
|
})
|
|
}
|
|
|
|
// CreateRunAccountChecked fences run admission against account disable. The
|
|
// conversation's stored owner is checked in the same final transaction.
|
|
func (s *Store) CreateRunAccountChecked(r *core.Run, allowImplicitDefault bool) error {
|
|
if r == nil || r.Owner == "" {
|
|
return ErrAccountForbidden
|
|
}
|
|
if r.ID == "" {
|
|
r.ID = newID("run")
|
|
}
|
|
r.CreatedAt = r.CreatedAt.UTC()
|
|
return s.withEffectiveAccountWrite(string(r.Owner), allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
|
var owner string
|
|
if err := tx.QueryRow(s.q(`SELECT owner FROM conversations WHERE id=?`), r.ConversationID).Scan(&owner); errors.Is(err, sql.ErrNoRows) {
|
|
return ErrNotFound
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
if owner != string(r.Owner) {
|
|
return ErrNotFound
|
|
}
|
|
_, err := tx.Exec(s.q(`INSERT INTO runs(id,parent_run_id,conversation_id,project_id,owner,kind,profile_id,status,phase,agent_name,created_at,instance_id) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`),
|
|
r.ID, r.ParentRunID, r.ConversationID, r.ProjectID, r.Owner, string(r.Kind), r.ProfileID, string(r.Status), string(r.Phase), r.AgentName, nowISO(), s.instanceID)
|
|
return err
|
|
})
|
|
}
|
|
|
|
// SetMessageRunIDAccountChecked links an admitted run only while its actor is
|
|
// still effective. Both rows must belong to the actor's conversation.
|
|
func (s *Store) SetMessageRunIDAccountChecked(actor, messageID, runID string, allowImplicitDefault bool) error {
|
|
var routeConversation string
|
|
if err := s.db.QueryRow(s.q(`SELECT conversation_id FROM messages WHERE id=?`), messageID).Scan(&routeConversation); errors.Is(err, sql.ErrNoRows) {
|
|
return ErrNotFound
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
|
|
if err := s.dialect.lockConversation(tx, routeConversation); err != nil {
|
|
return err
|
|
}
|
|
var messageConversation, messageOwner, runConversation, runOwner string
|
|
if err := tx.QueryRow(s.q(`SELECT m.conversation_id,c.owner FROM messages m JOIN conversations c ON c.id=m.conversation_id WHERE m.id=?`), messageID).
|
|
Scan(&messageConversation, &messageOwner); errors.Is(err, sql.ErrNoRows) {
|
|
return ErrNotFound
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
if err := tx.QueryRow(s.q(`SELECT conversation_id,owner FROM runs WHERE id=?`), runID).Scan(&runConversation, &runOwner); errors.Is(err, sql.ErrNoRows) {
|
|
return ErrNotFound
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
if messageConversation != routeConversation || runConversation != routeConversation || messageOwner != actor || runOwner != actor {
|
|
return ErrNotFound
|
|
}
|
|
res, err := tx.Exec(s.q(`UPDATE messages SET run_id=? WHERE id=? AND conversation_id=?`), runID, messageID, routeConversation)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, err := res.RowsAffected(); err != nil {
|
|
return err
|
|
} else if n != 1 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
})
|
|
}
|