Files
Agent-System-Backend/internal/store/projects.go
weichenandCursor 613c068c79 Isolate scratch sessions from project-only capabilities
Why: A scratch session is a private general-agent chat; it must work for its
owner without the RDM provider and must not share documents or context with any
other conversation.
Evidence: agent-exec run fb04c4a9 (pi, success); go test ./internal/store ./internal/server ./internal/runmgr ./internal/app pass.
Impact: Owner work/read on scratch bypasses the domain provider; non-owners and
shared adapters get NotFound. Scratch runs skip project context, knowledge
retrieval, owner/project context entries, user/project memory, and cross-session
reads (both directions). Knowledge add/search 400, rdm.* and formal project
commands 403, settings writes 400, uploads skip indexing, and deleting the sole
conversation removes the scratch project.
Refs: Agent-System-Dev docs/superpowers/specs/2026-10-22-scratch-session-general-agent-design.md

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 02:53:31 +00:00

592 lines
20 KiB
Go
Raw Permalink 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
}
// ListProjects 不含 scratch 项目:临时会话不作为普通项目出现在列表里。
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()
all, err := scanProjects(rows)
if err != nil {
return nil, err
}
return withoutScratchProjects(all), nil
}
func withoutScratchProjects(in []core.Project) []core.Project {
out := make([]core.Project, 0, len(in))
for _, p := range in {
if IsScratchProject(p.Settings) {
continue
}
out = append(out, p)
}
return out
}
// ListAllProjects 全量项目(含他人 owner;EXT-15)。仅供服务端在共享适配器
// 已注册时枚举候选——调用方须逐项目过适配器,不得直接下发全表。
// 与 ListProjects 一致,scratch 项目不进入候选。
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)
}
if IsScratchProject(p.Settings) {
continue
}
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 := decodeProjectSettings(settings)
merged := core.MergeSettings(cur, values)
if err := rejectScratchMarkerWrite(cur, merged); err != nil {
return err
}
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 := decodeProjectSettings(settings)
merged := core.MergeSettings(cur, values)
if err := rejectScratchMarkerWrite(cur, merged); err != nil {
return err
}
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) {
// 项目归属校验:会话必须落在请求者的项目下;scratch 项目保持 1:1。
var settings string
err := s.db.QueryRow(s.q(`SELECT settings FROM projects WHERE id=? AND owner=?`), projectID, owner).Scan(&settings)
if errors.Is(err, sql.ErrNoRows) {
return core.Conversation{}, ErrNotFound
}
if err != nil {
return core.Conversation{}, err
}
if err := refuseScratchConversation(settings); 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 settings string
err := s.db.QueryRow(s.q(`SELECT settings FROM projects WHERE id=?`), projectID).Scan(&settings)
if errors.Is(err, sql.ErrNoRows) {
return core.Conversation{}, ErrNotFound
}
if err != nil {
return core.Conversation{}, err
}
if err := refuseScratchConversation(settings); 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
}
// DeleteConversation deletes one conversation; deleting the sole conversation of
// a scratch project also removes that scratch project, so a scratch session is
// never left as an empty shell project.
func (s *Store) DeleteConversation(owner, id string) error {
return s.deleteConversationCascade(s.db, owner, id, func() 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 deletes the conversation; when it was the
// sole conversation of a scratch project, that scratch project goes with it in
// the same write transaction, so a scratch session cannot be left half-deleted.
func (s *Store) DeleteConversationAccountChecked(actor, id string, allowImplicitDefault bool) error {
return s.withOwnedConversationWrite(actor, id, allowImplicitDefault, func(tx *sql.Tx) error {
return s.deleteConversationCascade(tx, actor, id, func() 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)
})
}