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) }) }