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>
231 lines
8.9 KiB
Go
231 lines
8.9 KiB
Go
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
|
||
}
|