Files
Agent-System-Backend/internal/store/projects.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

557 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store
import (
"database/sql"
"errors"
"fmt"
"strings"
"time"
core "agent-system-core"
)
// ---- 项目与会话(租户域:owner 过滤是硬边界,跨租户一律 NotFound)----
func (s *Store) CreateProject(owner, name string) (core.Project, error) {
p := core.Project{ID: newID("prj"), Owner: owner, Name: name, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), Settings: map[string]any{}}
_, err := s.db.Exec(s.q(`INSERT INTO projects(id,owner,name,settings,created_at,updated_at) VALUES(?,?,?,?,?,?)`),
p.ID, owner, p.Name, "{}", nowISO(), nowISO())
return p, err
}
func (s *Store) ListProjects(owner string) ([]core.Project, error) {
rows, err := s.db.Query(s.q(`SELECT id,name,settings,created_at,updated_at FROM projects WHERE owner=? ORDER BY updated_at DESC`), owner)
if err != nil {
return nil, err
}
defer rows.Close()
return scanProjects(rows)
}
// ListAllProjects 全量项目(含他人 owner;EXT-15)。仅供服务端在共享适配器
// 已注册时枚举候选——调用方须逐项目过适配器,不得直接下发全表。
func (s *Store) ListAllProjects() ([]core.Project, error) {
rows, err := s.db.Query(s.q(`SELECT id,owner,name,settings,created_at,updated_at FROM projects ORDER BY updated_at DESC`))
if err != nil {
return nil, err
}
defer rows.Close()
out := []core.Project{}
for rows.Next() {
var p core.Project
var settings, ca, ua string
if err := rows.Scan(&p.ID, &p.Owner, &p.Name, &settings, &ca, &ua); err != nil {
return nil, err
}
p.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca)
p.UpdatedAt, _ = time.Parse(time.RFC3339Nano, ua)
if settings != "" && settings != "{}" {
p.Settings, _ = unmarshalJSON[map[string]any](settings)
}
out = append(out, p)
}
return out, rows.Err()
}
func (s *Store) GetProject(owner, id string) (core.Project, error) {
var p core.Project
var settings, ca, ua string
err := s.db.QueryRow(s.q(`SELECT id,name,settings,created_at,updated_at FROM projects WHERE id=? AND owner=?`), id, owner).
Scan(&p.ID, &p.Name, &settings, &ca, &ua)
if errors.Is(err, sql.ErrNoRows) {
return p, ErrNotFound
}
if err != nil {
return p, err
}
p.Owner = owner
p.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca)
p.UpdatedAt, _ = time.Parse(time.RFC3339Nano, ua)
if settings != "" && settings != "{}" {
p.Settings, _ = unmarshalJSON[map[string]any](settings)
}
return p, nil
}
// UpdateProjectSettings 读改写走事务 + scope 锁:并发合并不再丢失更新。
func (s *Store) UpdateProjectSettings(owner, id string, values map[string]any) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if err := s.dialect.lockScope(tx, owner, "project:"+id); err != nil {
return err
}
var settings string
err = tx.QueryRow(s.q(`SELECT settings FROM projects WHERE id=? AND owner=?`), id, owner).Scan(&settings)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
cur := map[string]any{}
if settings != "" && settings != "{}" {
cur, _ = unmarshalJSON[map[string]any](settings)
}
merged := core.MergeSettings(cur, values)
res, err := tx.Exec(s.q(`UPDATE projects SET settings=?, updated_at=? WHERE id=? AND owner=?`), marshalJSON(merged), nowISO(), id, owner)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return tx.Commit()
}
func (s *Store) RenameProject(owner, id, name string) error {
return s.updateProjectRow(owner, id, `UPDATE projects SET name=?, updated_at=? WHERE id=? AND owner=?`, name, nowISO(), id, owner)
}
func (s *Store) DeleteProject(owner, id string) error {
return s.updateProjectRow(owner, id, `DELETE FROM projects WHERE id=? AND owner=?`, id, owner)
}
func (s *Store) updateProjectRow(owner, id, query string, args ...any) error {
res, err := s.db.Exec(s.q(query), args...)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// CreateProjectAccountChecked checks the actor's effective state in the final
// transaction and makes that actor the new project's owner.
func (s *Store) CreateProjectAccountChecked(actor, name string, allowImplicitDefault bool) (core.Project, error) {
var project core.Project
err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
now := time.Now().UTC()
project = core.Project{
ID: newID("prj"), Owner: actor, Name: name,
CreatedAt: now, UpdatedAt: now, Settings: map[string]any{},
}
_, err := tx.Exec(s.q(`INSERT INTO projects(id,owner,name,settings,created_at,updated_at) VALUES(?,?,?,?,?,?)`),
project.ID, project.Owner, project.Name, "{}", now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
return err
})
if err != nil {
return core.Project{}, err
}
return project, nil
}
// projectForAccountWrite locks the stored project row after actor admission
// and returns ErrNotFound unless that actor owns it.
func (s *Store) projectForAccountWrite(tx *sql.Tx, actor, id string) (string, error) {
var owner, settings string
query := s.q(`SELECT owner,settings FROM projects WHERE id=?`)
if s.Dialect() == "postgres" {
query += ` FOR UPDATE`
}
err := tx.QueryRow(query, id).Scan(&owner, &settings)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
if err != nil {
return "", err
}
if owner != actor {
return "", ErrNotFound
}
return settings, nil
}
// RenameProjectAccountChecked keeps the legacy owner-only mutation semantics.
func (s *Store) RenameProjectAccountChecked(actor, id, name string, allowImplicitDefault bool) error {
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
if _, err := s.projectForAccountWrite(tx, actor, id); err != nil {
return err
}
res, err := tx.Exec(s.q(`UPDATE projects SET name=?,updated_at=? WHERE id=? AND owner=?`), name, nowISO(), id, actor)
if err != nil {
return err
}
if n, err := res.RowsAffected(); err != nil {
return err
} else if n != 1 {
return ErrNotFound
}
return nil
})
}
// DeleteProjectAccountChecked keeps the legacy owner-only deletion semantics.
func (s *Store) DeleteProjectAccountChecked(actor, id string, allowImplicitDefault bool) error {
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
if _, err := s.projectForAccountWrite(tx, actor, id); err != nil {
return err
}
res, err := tx.Exec(s.q(`DELETE FROM projects 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
})
}
// UpdateProjectSettingsAccountChecked merges settings under the actor lock,
// then the project scope and row locks, in the final write transaction.
func (s *Store) UpdateProjectSettingsAccountChecked(actor, id string, values map[string]any, allowImplicitDefault bool) error {
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
if err := s.dialect.lockScope(tx, actor, "project:"+id); err != nil {
return err
}
settings, err := s.projectForAccountWrite(tx, actor, id)
if err != nil {
return err
}
cur := map[string]any{}
if settings != "" && settings != "{}" {
cur, _ = unmarshalJSON[map[string]any](settings)
}
merged := core.MergeSettings(cur, values)
res, err := tx.Exec(s.q(`UPDATE projects SET settings=?,updated_at=? WHERE id=? AND owner=?`), marshalJSON(merged), nowISO(), 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 scanProjects(rows *sql.Rows) ([]core.Project, error) {
out := []core.Project{}
for rows.Next() {
var p core.Project
var settings string
var ca, ua string
if err := rows.Scan(&p.ID, &p.Name, &settings, &ca, &ua); err != nil {
return nil, err
}
p.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca)
p.UpdatedAt, _ = time.Parse(time.RFC3339Nano, ua)
if settings != "" && settings != "{}" {
p.Settings, _ = unmarshalJSON[map[string]any](settings)
}
out = append(out, p)
}
return out, rows.Err()
}
// ---- 会话 ----
func (s *Store) CreateConversation(owner, projectID, title, profileID string) (core.Conversation, error) {
// 项目归属校验:会话必须落在请求者的项目下。
var one int
err := s.db.QueryRow(s.q(`SELECT 1 FROM projects WHERE id=? AND owner=?`), projectID, owner).Scan(&one)
if errors.Is(err, sql.ErrNoRows) {
return core.Conversation{}, ErrNotFound
}
if err != nil {
return core.Conversation{}, err
}
c := core.Conversation{ID: newID("cnv"), ProjectID: projectID, Owner: owner, Title: title, ProfileID: profileID, AllowCrossRead: true, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}
_, err = s.db.Exec(s.q(`INSERT INTO conversations(id,project_id,owner,title,profile_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?)`),
c.ID, c.ProjectID, owner, c.Title, c.ProfileID, nowISO(), nowISO())
return c, err
}
// CreateConversationInProject 在共享项目下建会话(EXT-15):只校验项目存在,
// 不校验归属(服务端已先按自有路径尝试、再经适配器放行);会话 owner 仍为创建者。
func (s *Store) CreateConversationInProject(owner, projectID, title, profileID string) (core.Conversation, error) {
var one int
err := s.db.QueryRow(s.q(`SELECT 1 FROM projects WHERE id=?`), projectID).Scan(&one)
if errors.Is(err, sql.ErrNoRows) {
return core.Conversation{}, ErrNotFound
}
if err != nil {
return core.Conversation{}, err
}
c := core.Conversation{ID: newID("cnv"), ProjectID: projectID, Owner: owner, Title: title, ProfileID: profileID, AllowCrossRead: true, CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}
_, err = s.db.Exec(s.q(`INSERT INTO conversations(id,project_id,owner,title,profile_id,created_at,updated_at) VALUES(?,?,?,?,?,?,?)`),
c.ID, c.ProjectID, owner, c.Title, c.ProfileID, nowISO(), nowISO())
return c, err
}
func (s *Store) ListConversations(owner, projectID string) ([]core.Conversation, error) {
rows, err := s.db.Query(s.q(`SELECT id,project_id,title,profile_id,pinned,archived,allow_cross_read,cross_private,permission_mode,session_note,created_at,updated_at FROM conversations WHERE project_id=? AND owner=? ORDER BY updated_at DESC`), projectID, owner)
if err != nil {
return nil, err
}
defer rows.Close()
out := []core.Conversation{}
for rows.Next() {
var c core.Conversation
if err := scanConversation(rows, &c); err != nil {
return nil, err
}
c.Owner = owner
out = append(out, c)
}
return out, rows.Err()
}
// ListTasks 跨项目任务列表(方案 A 侧栏数据源):默认仅未归档;includeArchived 含归档。
func (s *Store) ListTasks(owner string, includeArchived bool) ([]core.Conversation, error) {
q := `SELECT id,project_id,title,profile_id,pinned,archived,allow_cross_read,cross_private,permission_mode,session_note,created_at,updated_at FROM conversations WHERE owner=?`
args := []any{owner}
if !includeArchived {
q += ` AND archived=?`
args = append(args, false)
}
q += ` ORDER BY pinned DESC, updated_at DESC`
rows, err := s.db.Query(s.q(q), args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []core.Conversation{}
for rows.Next() {
var c core.Conversation
if err := scanConversation(rows, &c); err != nil {
return nil, err
}
c.Owner = owner
out = append(out, c)
}
return out, rows.Err()
}
func scanConversation(rows interface{ Scan(dest ...any) error }, c *core.Conversation) error {
var ca, ua string
// 直接扫 bool:PG 原生 boolean;SQLite 整数 0/1 经 driver.Bool 转换同样成立。
if err := rows.Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &c.Pinned, &c.Archived, &c.AllowCrossRead, &c.CrossPrivate, &c.PermissionMode, &c.SessionNote, &ca, &ua); err != nil {
return err
}
c.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca)
c.UpdatedAt, _ = time.Parse(time.RFC3339Nano, ua)
return nil
}
func (s *Store) GetConversation(owner, id string) (core.Conversation, error) {
var c core.Conversation
var ca, ua string
err := s.db.QueryRow(s.q(`SELECT id,project_id,title,profile_id,pinned,archived,allow_cross_read,cross_private,permission_mode,session_note,created_at,updated_at FROM conversations WHERE id=? AND owner=?`), id, owner).
Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &c.Pinned, &c.Archived, &c.AllowCrossRead, &c.CrossPrivate, &c.PermissionMode, &c.SessionNote, &ca, &ua)
if errors.Is(err, sql.ErrNoRows) {
return c, ErrNotFound
}
if err != nil {
return c, err
}
c.Owner = owner
c.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca)
c.UpdatedAt, _ = time.Parse(time.RFC3339Nano, ua)
return c, nil
}
// TouchConversation 刷新会话 updated_at(内部路径;归属已在上游校验)。
func (s *Store) TouchConversation(id string) {
s.db.Exec(s.q(`UPDATE conversations SET updated_at=? WHERE id=?`), nowISO(), id)
}
func (s *Store) UpdateConversation(owner, id, title, profileID string) error {
var query string
var args []any
if title == "" {
query = `UPDATE conversations SET profile_id=?, updated_at=? WHERE id=? AND owner=?`
args = []any{profileID, nowISO(), id, owner}
} else {
query = `UPDATE conversations SET title=?, profile_id=?, updated_at=? WHERE id=? AND owner=?`
args = []any{title, profileID, nowISO(), id, owner}
}
res, err := s.db.Exec(s.q(query), args...)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// SetConversationFlags 更新会话标记(nil = 该字段不动;方案 A R1 + 跨会话读取提案)。
// conversations 的四个标记列在 PG 档是 BOOLEAN——绑 Go bool,不绑 boolInt 整数。
func (s *Store) SetConversationFlags(owner, id string, pinned, archived, allowCrossRead, crossPrivate *bool) error {
var sets []string
var args []any
if pinned != nil {
sets = append(sets, "pinned=?")
args = append(args, *pinned)
}
if archived != nil {
sets = append(sets, "archived=?")
args = append(args, *archived)
}
if allowCrossRead != nil {
sets = append(sets, "allow_cross_read=?")
args = append(args, *allowCrossRead)
}
if crossPrivate != nil {
sets = append(sets, "cross_private=?")
args = append(args, *crossPrivate)
}
if len(sets) == 0 {
return nil
}
args = append(args, nowISO(), id, owner)
res, err := s.db.Exec(s.q(`UPDATE conversations SET `+strings.Join(sets, ", ")+`, updated_at=? WHERE id=? AND owner=?`), args...)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) SetPermissionMode(owner, id, mode string) error {
switch mode {
case "", "default", "plan", "auto_edit":
default:
return fmt.Errorf("store: unknown permission mode %q", mode)
}
if mode == "default" {
mode = ""
}
res, err := s.db.Exec(s.q(`UPDATE conversations SET permission_mode=?, updated_at=? WHERE id=? AND owner=?`), mode, nowISO(), id, owner)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) DeleteConversation(owner, id string) error {
return s.updateProjectRow(owner, id, `DELETE FROM conversations WHERE id=? AND owner=?`, id, owner)
}
// withOwnedConversationWrite locks the actor before the conversation and
// resolves ownership from the stored row in the final write transaction.
func (s *Store) withOwnedConversationWrite(actor, id string, allowImplicitDefault bool, write func(*sql.Tx) error) error {
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
if err := s.dialect.lockConversation(tx, id); err != nil {
return err
}
query := s.q(`SELECT owner FROM conversations WHERE id=?`)
if s.Dialect() == "postgres" {
query += ` FOR UPDATE`
}
var owner string
err := tx.QueryRow(query, id).Scan(&owner)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
if owner != actor {
return ErrNotFound
}
return write(tx)
})
}
func (s *Store) execOwnedConversationWrite(tx *sql.Tx, query string, args ...any) error {
res, err := tx.Exec(s.q(query), args...)
if err != nil {
return err
}
if n, err := res.RowsAffected(); err != nil {
return err
} else if n != 1 {
return ErrNotFound
}
return nil
}
// UpdateConversationAccountChecked changes title/profile only for its active owner.
func (s *Store) UpdateConversationAccountChecked(actor, id, title, profileID string, allowImplicitDefault bool) error {
return s.withOwnedConversationWrite(actor, id, allowImplicitDefault, func(tx *sql.Tx) error {
if title == "" {
return s.execOwnedConversationWrite(tx,
`UPDATE conversations SET profile_id=?,updated_at=? WHERE id=? AND owner=?`,
profileID, nowISO(), id, actor)
}
return s.execOwnedConversationWrite(tx,
`UPDATE conversations SET title=?,profile_id=?,updated_at=? WHERE id=? AND owner=?`,
title, profileID, nowISO(), id, actor)
})
}
// DeleteConversationAccountChecked retains the legacy owner-only delete rule.
func (s *Store) DeleteConversationAccountChecked(actor, id string, allowImplicitDefault bool) error {
return s.withOwnedConversationWrite(actor, id, allowImplicitDefault, func(tx *sql.Tx) error {
return s.execOwnedConversationWrite(tx, `DELETE FROM conversations WHERE id=? AND owner=?`, id, actor)
})
}
// SetConversationFlagsAccountChecked applies the legacy partial flag update
// while checking the actor and owner even when all flags are nil.
func (s *Store) SetConversationFlagsAccountChecked(actor, id string, pinned, archived, allowCrossRead, crossPrivate *bool, allowImplicitDefault bool) error {
var sets []string
var args []any
for _, field := range []struct {
name string
value *bool
}{
{"pinned", pinned}, {"archived", archived},
{"allow_cross_read", allowCrossRead}, {"cross_private", crossPrivate},
} {
if field.value != nil {
sets = append(sets, field.name+"=?")
args = append(args, *field.value)
}
}
return s.withOwnedConversationWrite(actor, id, allowImplicitDefault, func(tx *sql.Tx) error {
if len(sets) == 0 {
return nil
}
return s.execOwnedConversationWrite(tx,
`UPDATE conversations SET `+strings.Join(sets, ", ")+`,updated_at=? WHERE id=? AND owner=?`,
append(args, nowISO(), id, actor)...)
})
}
// SetPermissionModeAccountChecked validates and stores the legacy mode values.
func (s *Store) SetPermissionModeAccountChecked(actor, id, mode string, allowImplicitDefault bool) error {
switch mode {
case "", "default", "plan", "auto_edit":
default:
return fmt.Errorf("store: unknown permission mode %q", mode)
}
if mode == "default" {
mode = ""
}
return s.withOwnedConversationWrite(actor, id, allowImplicitDefault, func(tx *sql.Tx) error {
return s.execOwnedConversationWrite(tx,
`UPDATE conversations SET permission_mode=?,updated_at=? WHERE id=? AND owner=?`,
mode, nowISO(), id, actor)
})
}
// SetSessionNoteAccountChecked stores the note for the active conversation owner.
func (s *Store) SetSessionNoteAccountChecked(actor, id, note string, allowImplicitDefault bool) error {
return s.withOwnedConversationWrite(actor, id, allowImplicitDefault, func(tx *sql.Tx) error {
return s.execOwnedConversationWrite(tx,
`UPDATE conversations SET session_note=?,updated_at=? WHERE id=? AND owner=?`,
note, nowISO(), id, actor)
})
}