Files
Agent-System-Backend/internal/store/scratch.go
weichenandCursor f3f70a7978
governance / governance (pull_request) Successful in 21s
ci / vet-and-test (pull_request) Successful in 1m27s
Mark scratch conversations in conversation responses
Why: The Frontend groups scratch sessions under a separate sidebar category and
needs an explicit marker instead of inferring it from the project list.
Evidence: agent-exec run 2d662c46 (pi, success); go test ./internal/store ./internal/server pass.
Impact: listTasks, listConversations, conversationDetail and POST /api/scratch-sessions
return scratch: true for scratch conversations; other conversations keep the same JSON (omitempty).
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 03:05:25 +00:00

231 lines
8.9 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"
"time"
core "agent-system-core"
)
// 临时会话(scratch session):一个 scratch 项目 + 恰好一个私有会话。
// scratch 属性只由项目 settings 的 host.kind 表达——它随项目行一起存储,
// 所以判定只需要一个读函数,任何调用点都不自己比对字符串。
const (
// ScratchHostKind 是 projects.settings.host.kind 的临时会话取值。
ScratchHostKind = "scratch"
// ScratchDefaultProjectName 是未指定名称时临时会话项目使用的默认名。
ScratchDefaultProjectName = "临时会话"
)
var (
// ErrScratchSessionSingle 会话与临时会话项目是 1:1:第二个会话被拒。
ErrScratchSessionSingle = errors.New("store: scratch session holds exactly one conversation")
// ErrScratchMarkerReserved 标记归服务端所有:客户端既不能给普通项目
// 加上 scratch,也不能从已有 scratch 项目上摘掉它。
ErrScratchMarkerReserved = errors.New("store: scratch host marker is server-owned")
)
// ScratchHostSettings 返回标记 scratch 项目的 settings 值。
func ScratchHostSettings() map[string]any {
return map[string]any{"host": map[string]any{"kind": ScratchHostKind}}
}
// IsScratchProject 报告 settings 是否带 scratch 宿主标记。已存储的
// projects.settings 与尚未合并的客户端 settings 都用它判定。
func IsScratchProject(settings map[string]any) bool {
host, ok := settings["host"].(map[string]any)
if !ok {
return false
}
kind, _ := host["kind"].(string)
return kind == ScratchHostKind
}
// ScratchProjectIDs 返回 owner 名下带 scratch 标记的项目 id 集合:一次查询读出
// 该 owner 的全部 projects.settings,逐行按 IsScratchProject 判定。列表响应要给
// 一批会话打标时走它,不必逐个会话回查项目;别人的 scratch 项目不在结果里。
func (s *Store) ScratchProjectIDs(owner string) (map[string]bool, error) {
rows, err := s.db.Query(s.q(`SELECT id,settings FROM projects WHERE owner=?`), owner)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]bool{}
for rows.Next() {
var id, settings string
if err := rows.Scan(&id, &settings); err != nil {
return nil, err
}
if IsScratchProject(decodeProjectSettings(settings)) {
out[id] = true
}
}
return out, rows.Err()
}
// IsScratchProjectID 是内部可信判定:按 id 读 projects.settings 的 host 标记。
// 调用点(HTTP 门禁、回合门禁、命令分发)一律走它,不自己比对标记字符串。
func (s *Store) IsScratchProjectID(projectID string) (bool, error) {
if projectID == "" {
return false, nil
}
p, err := s.ProjectMetadata(projectID)
if err != nil {
return false, err
}
return IsScratchProject(p.Settings), nil
}
// ScratchProjectDecision 是临时会话的归属判定,回答“这个调用是否发生在某个
// 临时会话里”:
// - handled=false:不是临时会话(或项目读不到),调用点继续走原来的判定面;
// - handled=true, err=nil:调用者是该临时会话的属主,放行且不必再问 RDM;
// - handled=true, err=ErrNotFound:临时会话不是共享资源,其他人一律不可见。
//
// 判据只来自存储里的属主列与 host 标记,不接受调用方自称的身份。
func (s *Store) ScratchProjectDecision(projectID, actor string) (bool, error) {
if projectID == "" {
return false, nil
}
p, err := s.ProjectMetadata(projectID)
if err != nil {
// 读不到项目(不存在或存储异常):存在性与共享判定留给原路径,
// 这里不把“读不到”当成临时会话,也就不会放宽任何门禁。
return false, nil
}
if !IsScratchProject(p.Settings) {
return false, nil
}
if p.Owner != core.ID(actor) {
return true, ErrNotFound
}
return true, nil
}
// scratchQuerier 是本文件里临时会话判定可用的两种执行面(*sql.DB 与 *sql.Tx),
// 于是同一个事务内的级联删除与 store 级删除共用一份语义。
type scratchQuerier interface {
QueryRow(string, ...any) *sql.Row
Exec(string, ...any) (sql.Result, error)
}
// scratchProjectOfConversation 返回会话所属的 scratch 项目 id;普通项目返回空串。
func (s *Store) scratchProjectOfConversation(q scratchQuerier, owner, conversationID string) (string, error) {
var projectID, settings string
err := q.QueryRow(s.q(`SELECT c.project_id, COALESCE(p.settings,'') FROM conversations c LEFT JOIN projects p ON p.id=c.project_id WHERE c.id=? AND c.owner=?`),
conversationID, owner).Scan(&projectID, &settings)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
if err != nil {
return "", err
}
if !IsScratchProject(decodeProjectSettings(settings)) {
return "", nil
}
return projectID, nil
}
// deleteScratchProjectIfEmpty 在会话已被删掉之后连带删掉它的临时会话项目:
// 临时会话项目只承载这一个会话,删会话即删临时会话(同一个执行面、同一个门禁)。
// 仍有会话时不动(1:1 由创建面保证,删除面不依赖该不变量做正确性);普通项目
// id 传进来也不会被删。
func (s *Store) deleteScratchProjectIfEmpty(q scratchQuerier, owner, projectID string) error {
if projectID == "" {
return nil
}
var remaining int
if err := q.QueryRow(s.q(`SELECT COUNT(1) FROM conversations WHERE project_id=?`), projectID).Scan(&remaining); err != nil {
return err
}
if remaining > 0 {
return nil
}
res, err := q.Exec(s.q(`DELETE FROM projects WHERE id=? AND owner=?`), projectID, owner)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// deleteConversationCascade 删除一个会话,并在它是临时会话唯一会话时连带删掉
// 临时会话项目。account-checked 路径在最终写事务内调用,legacy 路径在同一执行
// 面上调用:语义只有一份。
func (s *Store) deleteConversationCascade(q scratchQuerier, owner, id string, del func() error) error {
scratchProject, err := s.scratchProjectOfConversation(q, owner, id)
if err != nil {
return err
}
if err := del(); err != nil {
return err
}
return s.deleteScratchProjectIfEmpty(q, owner, scratchProject)
}
// decodeProjectSettings 把存储的 settings JSON 读成 map;缺省或损坏都是空表。
func decodeProjectSettings(raw string) map[string]any {
if raw == "" || raw == "{}" {
return map[string]any{}
}
cur, err := unmarshalJSON[map[string]any](raw)
if err != nil || cur == nil {
return map[string]any{}
}
return cur
}
// rejectScratchMarkerWrite 拒绝改变 scratch 标记的客户端写入。保持标记不变的
// 普通设置写入(例如给 scratch 项目改别的键)照旧通过。
func rejectScratchMarkerWrite(current, merged map[string]any) error {
if IsScratchProject(current) != IsScratchProject(merged) {
return ErrScratchMarkerReserved
}
return nil
}
// refuseScratchConversation 按存储的 projects.settings 判定 1:1:scratch 项目
// 下再建会话即拒,错误在会话插入之前返回。
func refuseScratchConversation(rawSettings string) error {
if IsScratchProject(decodeProjectSettings(rawSettings)) {
return ErrScratchSessionSingle
}
return nil
}
// CreateScratchSessionAccountChecked 在一个事务里建出 scratch 项目与它唯一的
// 会话:项目写入 host.kind 标记,会话写 cross_private=true。两者都发生在校验
// 过的 actor 行锁之下,所以临时会话与普通项目走同一条账号有效性边界。
// 调用方不需要(也不应)先调 CreateProjectAccountChecked——该路径不带标记,
// 无法表示临时会话。
func (s *Store) CreateScratchSessionAccountChecked(actor, name, title, profileID string, allowImplicitDefault bool) (core.Project, core.Conversation, error) {
now := time.Now().UTC()
project := core.Project{
ID: newID("prj"), Owner: core.ID(actor), Name: name,
CreatedAt: now, UpdatedAt: now, Settings: ScratchHostSettings(),
}
conversation := core.Conversation{
ID: newID("cnv"), ProjectID: project.ID, Owner: core.ID(actor), Title: title, ProfileID: profileID,
AllowCrossRead: true, CrossPrivate: true, CreatedAt: now, UpdatedAt: now,
}
err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
if _, err := tx.Exec(s.q(`INSERT INTO projects(id,owner,name,settings,created_at,updated_at) VALUES(?,?,?,?,?,?)`),
project.ID, actor, project.Name, marshalJSON(project.Settings),
now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano)); err != nil {
return err
}
_, err := tx.Exec(s.q(`INSERT INTO conversations(id,project_id,owner,title,profile_id,cross_private,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)`),
conversation.ID, conversation.ProjectID, actor, conversation.Title, conversation.ProfileID,
true, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
return err
})
if err != nil {
return core.Project{}, core.Conversation{}, err
}
return project, conversation, nil
}