Files
Agent-System-Backend/internal/store/surfaces.go
T
weichen adcd8fefd2 Fence U3 account writes and stabilize directory cursors
Why: Account disable could be followed by late Backend business writes, API key creation could implicitly create users, account bootstrap locks could invert, and directory cursors were process-local or leaked out-of-scope changes.

Evidence: Local go test ./... and isolated PostgreSQL package suites passed on the staged candidate; exact committed-head SELF replay and stage 6 human review remain pending.

Impact: Adds final account transactions and external-effect checks across HTTP, Host, Bus and run manager; tightens API key issuance and persists versioned cursor signing. Delegated RDM rename still needs the separately proposed real-operator contract, so A1 and stage 6 are not complete.

Refs: Documents U3 r1, approved A1-A4 amendment fb3a821, pending delegated-projection amendment 5783940, Dev feature/iam-u3-account-directory-recovery.
2026-10-14 07:33:54 +00:00

384 lines
14 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"
"encoding/json"
"errors"
"fmt"
"time"
core "agent-system-core"
)
// ---- Generative UI Surface 存储(提案 U2;迁移 v21)----
// ErrRevisionConflict 是 revision 乐观并发冲突:携带当前最新版本供调用方对齐后重试。
type ErrRevisionConflict struct {
Current int
}
func (e *ErrRevisionConflict) Error() string {
return fmt.Sprintf("surface: revision conflict (current=%d)", e.Current)
}
// SurfaceRecord 是 surfaces 表的行(页面元数据;规格在修订行)。
type SurfaceRecord struct {
SurfaceID string
Owner string
ProjectID string
ConversationID string
PluginID string
Compat string
LatestRevision int
CreatedAt string
UpdatedAt string
}
// SurfaceRevisionRow 是 surface_revisions 表的行。
type SurfaceRevisionRow struct {
SurfaceID string
Revision int
BaseRevision int
ContextVersion string
ActivationEpoch int64
Spec string
SemanticText string
EventPending bool
CreatedAt string
}
// ActivationRow 是 plugin_activation 表的行:Surface 能力的执行级启禁状态。
// 无行 = {Enabled:false, Epoch:0}——新插件默认关闭(KTD7,不沿用 grants 缺省 true)。
type ActivationRow struct {
Owner string
PluginID string
Enabled bool
Epoch int64
UpdatedAt string
}
func (s *Store) GetActivation(owner, pluginID string) (ActivationRow, error) {
var row ActivationRow
var enabled int
err := s.db.QueryRow(s.q(`SELECT owner,plugin_id,enabled,epoch,updated_at FROM plugin_activation WHERE owner=? AND plugin_id=?`),
owner, pluginID).Scan(&row.Owner, &row.PluginID, &enabled, &row.Epoch, &row.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return ActivationRow{Owner: owner, PluginID: pluginID, Enabled: false, Epoch: 0}, nil
}
if err != nil {
return row, err
}
row.Enabled = enabled != 0
return row, nil
}
// SetActivation 事务内读-改-写:每次状态变更(含同值重设)递增 epoch,
// 使禁用/启用前的在途提交按 epoch 失配被拒(KTD7 禁用是准入截止点)。
func (s *Store) SetActivation(owner, pluginID string, enabled bool) (ActivationRow, error) {
tx, err := s.db.Begin()
if err != nil {
return ActivationRow{}, err
}
defer tx.Rollback()
row, err := s.setActivationTx(tx, owner, pluginID, enabled)
if err != nil {
return ActivationRow{}, err
}
if err := tx.Commit(); err != nil {
return ActivationRow{}, err
}
return row, nil
}
// SetActivationAccountChecked serializes the actor's effective state and the
// activation change in one final transaction.
func (s *Store) SetActivationAccountChecked(actor, pluginID string, enabled, allowImplicitDefault bool) (ActivationRow, error) {
var row ActivationRow
err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
var err error
row, err = s.setActivationTx(tx, actor, pluginID, enabled)
return err
})
return row, err
}
func (s *Store) setActivationTx(tx *sql.Tx, owner, pluginID string, enabled bool) (ActivationRow, error) {
now := time.Now().UTC().Format(time.RFC3339)
var epoch int64
err := tx.QueryRow(s.q(`SELECT epoch FROM plugin_activation WHERE owner=? AND plugin_id=?`), owner, pluginID).Scan(&epoch)
if errors.Is(err, sql.ErrNoRows) {
epoch = 0
} else if err != nil {
return ActivationRow{}, err
}
epoch++
flag := 0
if enabled {
flag = 1
}
if _, err := tx.Exec(s.q(`INSERT INTO plugin_activation(owner,plugin_id,enabled,epoch,updated_at) VALUES(?,?,?,?,?)
ON CONFLICT(owner, plugin_id) DO UPDATE SET enabled=excluded.enabled, epoch=excluded.epoch, updated_at=excluded.updated_at`),
owner, pluginID, flag, epoch, now); err != nil {
return ActivationRow{}, err
}
return ActivationRow{Owner: owner, PluginID: pluginID, Enabled: enabled, Epoch: epoch, UpdatedAt: now}, nil
}
// SaveSurfaceSnapshot 保存一个新修订(乐观并发):baseRevision=0 表示新建。
// 身份信封字段以参数为准(服务端绑定),不采信快照内自报值;返回带最终
// revision 的快照(CAS 命中后的实际版本号)。
func (s *Store) SaveSurfaceSnapshot(snap core.SurfaceSnapshot) (core.SurfaceSnapshot, error) {
tx, err := s.db.Begin()
if err != nil {
return snap, err
}
defer tx.Rollback()
snap, err = s.saveSurfaceSnapshotTx(tx, snap)
if err != nil {
return snap, err
}
return snap, tx.Commit()
}
// SaveSurfaceSnapshotAccountChecked ties the revision CAS to the actor state.
func (s *Store) SaveSurfaceSnapshotAccountChecked(actor string, snap core.SurfaceSnapshot, allowImplicitDefault bool) (core.SurfaceSnapshot, error) {
if actor != snap.Identity.Owner {
return snap, ErrAccountForbidden
}
err := s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
var err error
snap, err = s.saveSurfaceSnapshotTx(tx, snap)
return err
})
return snap, err
}
func (s *Store) saveSurfaceSnapshotTx(tx *sql.Tx, snap core.SurfaceSnapshot) (core.SurfaceSnapshot, error) {
now := time.Now().UTC().Format(time.RFC3339)
specJSON, err := json.Marshal(snap.Spec)
if err != nil {
return snap, err
}
if len(specJSON) > core.SurfaceMaxBytes {
return snap, fmt.Errorf("surface: spec %d bytes exceeds %d limit", len(specJSON), core.SurfaceMaxBytes)
}
compatJSON, _ := json.Marshal(snap.Compat)
var latest int
err = tx.QueryRow(s.q(`SELECT latest_revision FROM surfaces WHERE surface_id=? AND owner=?`),
string(snap.Identity.SurfaceID), snap.Identity.Owner).Scan(&latest)
switch {
case errors.Is(err, sql.ErrNoRows):
if snap.BaseRevision != 0 {
return snap, &ErrRevisionConflict{Current: 0}
}
if _, err := tx.Exec(s.q(`INSERT INTO surfaces(surface_id,owner,project_id,conversation_id,plugin_id,compat,latest_revision,created_at,updated_at)
VALUES(?,?,?,?,?,?,1,?,?)`),
string(snap.Identity.SurfaceID), snap.Identity.Owner, string(snap.Identity.ProjectID),
string(snap.Identity.ConversationID), snap.Identity.PluginID, string(compatJSON), now, now); err != nil {
return snap, err
}
snap.Revision = 1
case err != nil:
return snap, err
default:
if snap.BaseRevision != latest {
return snap, &ErrRevisionConflict{Current: latest}
}
snap.Revision = latest + 1
if _, err := tx.Exec(s.q(`UPDATE surfaces SET latest_revision=?, updated_at=? WHERE surface_id=? AND owner=?`),
snap.Revision, now, string(snap.Identity.SurfaceID), snap.Identity.Owner); err != nil {
return snap, err
}
}
if _, err := tx.Exec(s.q(`INSERT INTO surface_revisions(surface_id,revision,base_revision,context_version,activation_epoch,spec,semantic_text,event_pending,created_at)
VALUES(?,?,?,?,?,?,?,?,?)`),
string(snap.Identity.SurfaceID), snap.Revision, snap.BaseRevision, snap.ContextVersion,
snap.ActivationEpoch, string(specJSON), snap.SemanticText, 0, now); err != nil {
return snap, err
}
return snap, nil
}
func (s *Store) GetSurface(owner, surfaceID string) (SurfaceRecord, error) {
var r SurfaceRecord
var compat string
err := s.db.QueryRow(s.q(`SELECT surface_id,owner,project_id,conversation_id,plugin_id,compat,latest_revision,created_at,updated_at
FROM surfaces WHERE surface_id=? AND owner=?`), surfaceID, owner).
Scan(&r.SurfaceID, &r.Owner, &r.ProjectID, &r.ConversationID, &r.PluginID, &compat, &r.LatestRevision, &r.CreatedAt, &r.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return r, ErrNotFound
}
r.Compat = compat
return r, err
}
// GetSurfaceSnapshot 取整页快照;revision=0 表示最新。
func (s *Store) GetSurfaceSnapshot(owner, surfaceID string, revision int) (core.SurfaceSnapshot, error) {
rec, err := s.GetSurface(owner, surfaceID)
if err != nil {
return core.SurfaceSnapshot{}, err
}
if revision <= 0 {
revision = rec.LatestRevision
}
var row SurfaceRevisionRow
var pending int
err = s.db.QueryRow(s.q(`SELECT surface_id,revision,base_revision,context_version,activation_epoch,spec,semantic_text,event_pending,created_at
FROM surface_revisions WHERE surface_id=? AND revision=?`), surfaceID, revision).
Scan(&row.SurfaceID, &row.Revision, &row.BaseRevision, &row.ContextVersion, &row.ActivationEpoch, &row.Spec, &row.SemanticText, &pending, &row.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return core.SurfaceSnapshot{}, ErrNotFound
}
if err != nil {
return core.SurfaceSnapshot{}, err
}
row.EventPending = pending != 0
var spec core.SurfaceSpec
if err := json.Unmarshal([]byte(row.Spec), &spec); err != nil {
return core.SurfaceSnapshot{}, fmt.Errorf("surface: corrupted spec row: %w", err)
}
var compat core.SurfaceCompat
_ = json.Unmarshal([]byte(rec.Compat), &compat)
return core.SurfaceSnapshot{
Identity: core.SurfaceIdentity{
SurfaceID: core.ID(rec.SurfaceID),
Owner: rec.Owner,
ProjectID: core.ID(rec.ProjectID),
ConversationID: core.ID(rec.ConversationID),
PluginID: rec.PluginID,
},
Compat: compat,
Revision: row.Revision,
BaseRevision: row.BaseRevision,
ContextVersion: row.ContextVersion,
ActivationEpoch: row.ActivationEpoch,
Spec: spec,
SemanticText: row.SemanticText,
}, nil
}
func (s *Store) ListSurfaces(owner, conversationID string) ([]core.SurfaceMeta, error) {
// 最新修订行的 spec 在 Go 侧解析出 title(json_extract 为 SQLite 方言,PG 不可用)。
rows, err := s.db.Query(s.q(`SELECT s.surface_id, s2.spec, s.plugin_id, s.latest_revision, s.conversation_id, s.updated_at
FROM surfaces s
JOIN surface_revisions s2 ON s2.surface_id=s.surface_id AND s2.revision=s.latest_revision
WHERE s.owner=? AND (?='' OR s.conversation_id=?) ORDER BY s.updated_at DESC`),
owner, conversationID, conversationID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []core.SurfaceMeta
for rows.Next() {
var m core.SurfaceMeta
var specJSON string
if err := rows.Scan(&m.SurfaceID, &specJSON, &m.PluginID, &m.Revision, &m.ConversationID, &m.UpdatedAt); err != nil {
return nil, err
}
var spec core.SurfaceSpec
_ = json.Unmarshal([]byte(specJSON), &spec)
m.Title = spec.Title
out = append(out, m)
}
return out, rows.Err()
}
// SurfaceOwner 反查页面归属(待恢复扫描行不带 owner)。
func (s *Store) SurfaceOwner(surfaceID string) (string, error) {
var owner string
err := s.db.QueryRow(s.q(`SELECT owner FROM surfaces WHERE surface_id=?`), surfaceID).Scan(&owner)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
return owner, err
}
// SetSurfaceEventPending 标记/清除修订行的投影事件待恢复态(写快照后事件发布失败,
// KTD「持久事件或重建快照恢复」;恢复扫描走 ListSurfaceEventsPending)。
func (s *Store) SetSurfaceEventPending(owner, surfaceID string, revision int, pending bool) error {
return s.setSurfaceEventPendingExec(s.db, owner, surfaceID, revision, pending)
}
// SetSurfaceEventPendingAccountChecked fences the post-snapshot recovery mark.
func (s *Store) SetSurfaceEventPendingAccountChecked(actor, surfaceID string, revision int, pending, allowImplicitDefault bool) error {
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
return s.setSurfaceEventPendingExec(tx, actor, surfaceID, revision, pending)
})
}
type surfaceExecer interface {
Exec(string, ...any) (sql.Result, error)
}
func (s *Store) setSurfaceEventPendingExec(db surfaceExecer, owner, surfaceID string, revision int, pending bool) error {
flag := 0
if pending {
flag = 1
}
res, err := db.Exec(s.q(`UPDATE surface_revisions SET event_pending=?
WHERE surface_id=? AND revision=? AND EXISTS(SELECT 1 FROM surfaces WHERE surface_id=? AND owner=?)`),
flag, surfaceID, revision, surfaceID, owner)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
// ListSurfaceEventsPending 扫描待恢复的修订行(全租户,恢复进程专用)。
func (s *Store) ListSurfaceEventsPending(limit int) ([]SurfaceRevisionRow, error) {
if limit <= 0 {
limit = 100
}
rows, err := s.db.Query(s.q(`SELECT surface_id,revision,base_revision,context_version,activation_epoch,spec,semantic_text,event_pending,created_at
FROM surface_revisions WHERE event_pending=1 ORDER BY created_at LIMIT ?`), limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []SurfaceRevisionRow
for rows.Next() {
var r SurfaceRevisionRow
var pending int
if err := rows.Scan(&r.SurfaceID, &r.Revision, &r.BaseRevision, &r.ContextVersion, &r.ActivationEpoch, &r.Spec, &r.SemanticText, &pending, &r.CreatedAt); err != nil {
return nil, err
}
r.EventPending = pending != 0
out = append(out, r)
}
return out, rows.Err()
}
// ListSurfacesByProject 项目级跨会话聚合(U6/R8):按 owner 授权作用域列出项目下
// 全部页面(按 updated_at 新→旧);owner 列在 surfaces 表上,跨 owner 天然隔离。
func (s *Store) ListSurfacesByProject(owner, projectID string, limit int) ([]core.SurfaceMeta, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
rows, err := s.db.Query(s.q(`SELECT s.surface_id, s2.spec, s.plugin_id, s.latest_revision, s.conversation_id, s.updated_at
FROM surfaces s
JOIN surface_revisions s2 ON s2.surface_id=s.surface_id AND s2.revision=s.latest_revision
WHERE s.owner=? AND s.project_id=? ORDER BY s.updated_at DESC LIMIT ?`),
owner, projectID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []core.SurfaceMeta
for rows.Next() {
var m core.SurfaceMeta
var specJSON string
if err := rows.Scan(&m.SurfaceID, &specJSON, &m.PluginID, &m.Revision, &m.ConversationID, &m.UpdatedAt); err != nil {
return nil, err
}
var spec core.SurfaceSpec
_ = json.Unmarshal([]byte(specJSON), &spec)
m.Title = spec.Title
out = append(out, m)
}
return out, rows.Err()
}