Files
Agent-System-Backend/internal/store/projects.go
T
weichenandCursor 8d82408bb7
ci / vet-and-test (pull_request) Successful in 41s
Bind archived filter as bool in ListTasks
Why: GET /api/conversations on the default unarchived view appended
AND archived=0. PostgreSQL stores conversations.archived as BOOLEAN
and rejects boolean = integer (42883), so listTasks returned 500.
The previous dialect fix bound parameters and scans but left this literal.

Evidence: before the change, TestConversationFlagsAndTaskList against
pg1 failed with SQLSTATE 42883. After: AGENT_PG_TEST_DSN set to pg1,
go test ./internal/store -count=1 passed; go vet ./... && go test ./...
passed on SQLite.

Impact: the default task list works on the postgres tier. SQLite still
binds the bool as 0/1. openTestStore accepts URL and keyword DSNs and
keeps public on search_path so an existing pg_trgm extension stays visible.

Refs: Agent-System-Dev feature/store-pg-archived-literal
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 03:18:10 +00:00

294 lines
9.6 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)
}
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
}
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
}
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)
}