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.
384 lines
14 KiB
Go
384 lines
14 KiB
Go
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()
|
||
}
|