Why: conversation transcripts were the only project channel not readable across sessions (artifacts/knowledge are already shared), which caused duplicate parallel work; see proposal 2026-09-30-cross-session-reading. Evidence: runmgr/crossread_test.go covers the full gate matrix (project gate on/off x outbound flag x private target x cross-project x cross-tenant x self-read x archived x limit clamping) against a real store + settings chain; store tests cover migration v11, flag persistence and tenant isolation; tools/peers_test.go covers args validation, transcript rendering and the untrusted-content banner; go test ./... green. Impact: setting crossSession.read (default off, schema-driven); conversation columns allow_cross_read (default true) and cross_private (default false) with PATCH support; builtin tools list_conversations and read_conversation gated fail-closed in runmgr toolCtx; nested agent-system-core pin bumped to fd44179 (Core PR #6). Refs: Agent-System-Documents proposals/2026-09-30-cross-session-reading.md (Dev task feature/cross-session-reading).
280 lines
9.1 KiB
Go
280 lines
9.1 KiB
Go
package store
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"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,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,created_at,updated_at FROM conversations WHERE owner=?`
|
|
if !includeArchived {
|
|
q += ` AND archived=0`
|
|
}
|
|
q += ` ORDER BY pinned DESC, updated_at DESC`
|
|
rows, err := s.db.Query(s.q(q), 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()
|
|
}
|
|
|
|
func scanConversation(rows interface{ Scan(dest ...any) error }, c *core.Conversation) error {
|
|
var ca, ua string
|
|
var pinned, archived, allowCrossRead, crossPrivate int
|
|
if err := rows.Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &pinned, &archived, &allowCrossRead, &crossPrivate, &ca, &ua); err != nil {
|
|
return err
|
|
}
|
|
c.Pinned = pinned == 1
|
|
c.Archived = archived == 1
|
|
c.AllowCrossRead = allowCrossRead == 1
|
|
c.CrossPrivate = crossPrivate == 1
|
|
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
|
|
var pinned, archived, allowCrossRead, crossPrivate int
|
|
err := s.db.QueryRow(s.q(`SELECT id,project_id,title,profile_id,pinned,archived,allow_cross_read,cross_private,created_at,updated_at FROM conversations WHERE id=? AND owner=?`), id, owner).
|
|
Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &pinned, &archived, &allowCrossRead, &crossPrivate, &ca, &ua)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return c, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return c, err
|
|
}
|
|
c.Owner = owner
|
|
c.Pinned = pinned == 1
|
|
c.Archived = archived == 1
|
|
c.AllowCrossRead = allowCrossRead == 1
|
|
c.CrossPrivate = crossPrivate == 1
|
|
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 + 跨会话读取提案)。
|
|
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, boolInt(*pinned))
|
|
}
|
|
if archived != nil {
|
|
sets = append(sets, "archived=?")
|
|
args = append(args, boolInt(*archived))
|
|
}
|
|
if allowCrossRead != nil {
|
|
sets = append(sets, "allow_cross_read=?")
|
|
args = append(args, boolInt(*allowCrossRead))
|
|
}
|
|
if crossPrivate != nil {
|
|
sets = append(sets, "cross_private=?")
|
|
args = append(args, boolInt(*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) DeleteConversation(owner, id string) error {
|
|
return s.updateProjectRow(owner, id, `DELETE FROM conversations WHERE id=? AND owner=?`, id, owner)
|
|
}
|