Files
Agent-System-Backend/internal/store/surface_actions.go
T
weichen f5eb27df86
ci / vet-and-test (pull_request) Successful in 1m10s
Add the surface action pipeline: ledger, guards, dispatch, submit (U5)
Why: The approved U5 scope needs the action half of the loop: an
idempotent ledger (migration v24), a single-choke SubmitAction (plugin
activation, current-revision, action whitelist, params-hash
idempotency), dispatch through the command bus so DangerHigh lands on
the existing inline confirm card with no Confirmed passthrough (the
endpoint does not even bind the field), the button-only
generative-ui.submit storing form data in the plugin KV ledger, and
catalog discipline rejecting unknown action ids at present time.

Evidence: go build/vet clean; full go test ./... green; surfacesvc/
genui/store suites also green under AGENT_PG_TEST_DSN; red matrix
covers disabled/stale-revision/cross-owner/unknown-action/idempotent
same-and-different-params/danger-high-await-confirm and the endpoint
forged-confirmed case.

Impact: Additive migration v24 + new endpoint/command; existing
endpoints and command semantics untouched (B-class whitelist starts
empty per the approved scope).

Refs: U5 action-scope r2 (approved, Documents #71); UI #46/#47;
Frontend #56; Dev task feature/generative-ui-u5
2026-10-11 14:24:31 +00:00

77 lines
3.0 KiB
Go

package store
import (
"database/sql"
"errors"
"time"
)
// ---- Generative UI Surface 动作台账(提案 U5;迁移 v23)----
// SurfaceActionRecord 是一次动作提交的台账行。state: submitted(已分发)/
// awaiting_confirm(高危确认卡挂起,执行权威在命令总线审计)/ committed /
// rejected(分发被拒)。同 requestId 重放读此行返回原回执。
type SurfaceActionRecord struct {
Owner string
SurfaceID string
ActionID string
RequestID string
Revision int
ParamsHash string
State string
Receipt string
CardID string
CreatedAt string
}
// ErrIdempotencyConflict 同 requestId 但参数不同:拒绝(KTD6)。
var ErrIdempotencyConflict = errors.New("surface action: request id reused with different params")
// SaveSurfaceActionIfAbsent 幂等写入:首个提交插入成功(first=true);
// 已存在时返回既有行(first=false)。参数一致性由调用方比对 ParamsHash。
func (s *Store) SaveSurfaceActionIfAbsent(rec SurfaceActionRecord) (SurfaceActionRecord, bool, error) {
rec.CreatedAt = time.Now().UTC().Format(time.RFC3339)
res, err := s.db.Exec(s.q(`INSERT INTO surface_actions(owner,surface_id,action_id,request_id,revision,params_hash,state,receipt,card_id,created_at)
VALUES(?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(owner, surface_id, action_id, request_id) DO NOTHING`),
rec.Owner, rec.SurfaceID, rec.ActionID, rec.RequestID, rec.Revision,
rec.ParamsHash, rec.State, rec.Receipt, rec.CardID, rec.CreatedAt)
if err != nil {
return rec, false, err
}
n, _ := res.RowsAffected()
existing, err := s.GetSurfaceAction(rec.Owner, rec.SurfaceID, rec.ActionID, rec.RequestID)
if err != nil {
return rec, false, err
}
return existing, n == 1, nil // RowsAffected=1 → 本调用完成首插(时间戳同秒不可靠)
}
// GetSurfaceAction 读取台账行(不存在 → ErrNotFound)。
func (s *Store) GetSurfaceAction(owner, surfaceID, actionID, requestID string) (SurfaceActionRecord, error) {
var r SurfaceActionRecord
err := s.db.QueryRow(s.q(`SELECT owner,surface_id,action_id,request_id,revision,params_hash,state,receipt,card_id,created_at
FROM surface_actions WHERE owner=? AND surface_id=? AND action_id=? AND request_id=?`),
owner, surfaceID, actionID, requestID).
Scan(&r.Owner, &r.SurfaceID, &r.ActionID, &r.RequestID, &r.Revision, &r.ParamsHash, &r.State, &r.Receipt, &r.CardID, &r.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
return r, ErrNotFound
}
return r, err
}
// UpdateSurfaceActionState 回写分发结果(首个提交提交后的状态迁移)。
func (s *Store) UpdateSurfaceActionState(owner, surfaceID, actionID, requestID, state, receipt, cardID string) error {
res, err := s.db.Exec(s.q(`UPDATE surface_actions SET state=?, receipt=?, card_id=?
WHERE owner=? AND surface_id=? AND action_id=? AND request_id=?`),
state, receipt, cardID, owner, surfaceID, actionID, requestID)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}