Files
Agent-System-Backend/internal/surfacesvc/surfacesvc.go
T
weichen f1302ed5b3 Enable canonical versioned Go module consumption
Why: Allow consumers to download an immutable module revision without nested source checkouts or local path replacements.

Evidence: SELF package tests and the prior canonical-path rejection are recorded in the Dev dependency-workspace task card; remote consumption is replayed against the committed candidate.

Impact: Change the module identity and its Go imports while preserving API behavior. Existing historical submodule revisions remain buildable.

Refs: Versioned dependencies U1/U3; feature/dependency-workspace
2026-10-13 15:47:16 +00:00

365 lines
13 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 surfacesvc 是 Generative UI Surface 的服务层(提案 U2):
// 提交校验(目录纪律)→ 启禁/epoch 准入 → owner 隔离存储(revision CAS)→
// 事件发布(失败转待恢复)。所有入口(插件命令句柄、直接 API)共用本层,
// 守卫不依赖入口自觉(KTD7:执行端统一守卫,禁用是准入截止点)。
package surfacesvc
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"log"
"git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/store"
core "git-ssh.u1680832.nyat.app/weichen/Agent-System-Core.git"
)
// Emitter 由宿主注入(与 cardsvc 同一事件管道):持久化、seq 排序、断线重放。
type Emitter func(conversationID, runID string, typ core.StreamEventType, payload any) error
var (
ErrNotFound = store.ErrNotFound
ErrDisabled = errors.New("surface: plugin not enabled for owner")
ErrStaleEpoch = errors.New("surface: activation epoch mismatch (stale in-flight submit)")
ErrUnavailable = errors.New("surface: capability not available")
)
// ErrInvalidSpec 携带字段级违规清单(目录=提示=校验三同源;供原 Agent 修复重提)。
type ErrInvalidSpec struct{ Errs []string }
func (e *ErrInvalidSpec) Error() string { return "surface: invalid spec: " + fmt.Sprint(e.Errs) }
// SubmitRequest 是一次页面提交(工具参数通道或直接 API 同形)。
// ObservedEpoch>0 时校验与当前 epoch 一致,拦截「禁用后迟到的在途结果」。
type SubmitRequest struct {
SurfaceID core.ID
ProjectID core.ID
ConversationID core.ID
SourceRunID core.ID
BaseRevision int
ObservedEpoch int64
ContextVersion string
SemanticText string
Spec json.RawMessage
}
type Service struct {
st *store.Store
emit Emitter
dispatch Dispatcher
now func() (t string)
logln func(format string, v ...any)
}
func New(st *store.Store, emit Emitter) *Service {
return &Service{st: st, emit: emit,
now: func() string { return "" },
logln: log.Printf}
}
// Submit 是唯一的写入咽喉:严格解码 → 目录校验 → 启禁/epoch → CAS 保存 → 事件。
// 写入成功但事件发布失败时标记待恢复并照常返回快照(可恢复的最终一致)。
func (s *Service) Submit(ctx context.Context, owner, pluginID string, req SubmitRequest) (core.SurfaceSnapshot, error) {
if owner == "" || pluginID == "" {
return core.SurfaceSnapshot{}, ErrUnavailable
}
act, err := s.st.GetActivation(owner, pluginID)
if err != nil {
return core.SurfaceSnapshot{}, err
}
if !act.Enabled {
return core.SurfaceSnapshot{}, ErrDisabled
}
if req.ObservedEpoch > 0 && req.ObservedEpoch != act.Epoch {
return core.SurfaceSnapshot{}, ErrStaleEpoch
}
spec, err := decodeSpecStrict(req.Spec)
if err != nil {
return core.SurfaceSnapshot{}, &ErrInvalidSpec{Errs: []string{"spec: " + err.Error()}}
}
if errs := core.ValidateSurfaceSpec(spec, nil); len(errs) > 0 {
// knownSources=nil:来源授权属 U3 数据绑定层,本层只做格式与纪律检查。
return core.SurfaceSnapshot{}, &ErrInvalidSpec{Errs: errs}
}
surfaceID := req.SurfaceID
if surfaceID == "" {
surfaceID = core.ID(store.NewID("surf"))
}
projectID := req.ProjectID
if projectID == "" && req.ConversationID != "" {
// U6:项目归属从会话权威反查(模型不能自报 project;会话表是唯一来源)。
if conv, err := s.st.GetConversation(owner, string(req.ConversationID)); err == nil {
projectID = conv.ProjectID
}
}
snap := core.SurfaceSnapshot{
Identity: core.SurfaceIdentity{
SurfaceID: surfaceID, Owner: owner, ProjectID: projectID, ConversationID: req.ConversationID,
SourceRunID: req.SourceRunID, PluginID: pluginID,
},
Compat: core.SurfaceCompat{SchemaVersion: core.SurfaceSchemaVersion, CatalogVersion: core.SurfaceCatalogVersion},
BaseRevision: req.BaseRevision,
ContextVersion: req.ContextVersion,
ActivationEpoch: act.Epoch,
Spec: spec,
SemanticText: req.SemanticText,
}
saved, err := s.st.SaveSurfaceSnapshot(snap)
if err != nil {
return saved, err
}
s.publish(saved, false)
return saved, nil
}
// Get 读取整页快照(owner 隔离)。禁用不阻断读取:历史页面按权限保留(KTD7)。
func (s *Service) Get(ctx context.Context, owner string, surfaceID core.ID) (core.SurfaceSnapshot, error) {
return s.st.GetSurfaceSnapshot(owner, string(surfaceID), 0)
}
// List 列出 owner 名下(可按会话过滤)的页面元数据。
func (s *Service) List(ctx context.Context, owner string, conversationID core.ID) ([]core.SurfaceMeta, error) {
return s.st.ListSurfaces(owner, string(conversationID))
}
// Activation 观察当前启禁与 epoch(调用方装配能力时快照 ObservedEpoch 用)。
func (s *Service) Activation(ctx context.Context, owner, pluginID string) (store.ActivationRow, error) {
return s.st.GetActivation(owner, pluginID)
}
// SetActivation 启/禁 Surface 能力:先持久化并递增 epoch,再返回(禁用返回即
// 准入截止点);审计留痕。新插件默认关闭(无行=disabled)。
func (s *Service) SetActivation(ctx context.Context, owner, pluginID string, enabled bool) (store.ActivationRow, error) {
row, err := s.st.SetActivation(owner, pluginID, enabled)
if err != nil {
return row, err
}
action := "surface.disabled"
if enabled {
action = "surface.enabled"
}
if err := s.st.AppendAuditEvent(store.AuditEvent{
Owner: owner, Actor: "host", Action: action,
Target: pluginID, Evidence: fmt.Sprintf("epoch=%d", row.Epoch),
}); err != nil {
return row, err
}
return row, nil
}
// publish 发布投影事件;失败则标记待恢复(快照已落盘,不回滚写入)。
func (s *Service) publish(snap core.SurfaceSnapshot, recovery bool) {
if s.emit == nil || snap.Identity.ConversationID == "" {
return
}
if err := s.emit(string(snap.Identity.ConversationID), string(snap.Identity.SourceRunID), core.EvSurfaceUpdated, snap); err != nil {
if markErr := s.st.SetSurfaceEventPending(snap.Identity.Owner, string(snap.Identity.SurfaceID), snap.Revision, true); markErr != nil {
s.logln("surface: event publish failed (%v) and pending-mark failed (%v) rev=%d", err, markErr, snap.Revision)
return
}
if recovery {
s.logln("surface: recovery emit failed again, kept pending rev=%d", snap.Revision)
}
}
}
// RecoverPendingEvents 重发待恢复的投影事件;返回恢复条数。
func (s *Service) RecoverPendingEvents(ctx context.Context) (int, error) {
rows, err := s.st.ListSurfaceEventsPending(100)
if err != nil {
return 0, err
}
n := 0
for _, row := range rows {
owner, err := s.st.SurfaceOwner(row.SurfaceID)
if err != nil {
continue
}
snap, err := s.st.GetSurfaceSnapshot(owner, row.SurfaceID, row.Revision)
if err != nil {
continue
}
s.publish(snap, true)
if err := s.st.SetSurfaceEventPending(owner, row.SurfaceID, row.Revision, false); err != nil {
continue
}
n++
}
return n, nil
}
// decodeSpecStrict 严格解码:未知字段拒绝(DisallowUnknownFields),并限制体积。
func decodeSpecStrict(raw json.RawMessage) (core.SurfaceSpec, error) {
if len(raw) == 0 {
return core.SurfaceSpec{}, errors.New("empty spec")
}
if len(raw) > core.SurfaceMaxBytes {
return core.SurfaceSpec{}, fmt.Errorf("spec %d bytes exceeds %d limit", len(raw), core.SurfaceMaxBytes)
}
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
var spec core.SurfaceSpec
if err := dec.Decode(&spec); err != nil {
return core.SurfaceSpec{}, err
}
if dec.More() {
return core.SurfaceSpec{}, errors.New("trailing data after spec")
}
return spec, nil
}
// ---- 动作提交(U5;已批准范围 r2:A 客户端本地不经此层;B/C 经此单一咽喉)----
// ErrUnknownAction 引用了未开放的动作 id(白名单外;KTD6 未知动作一律拒绝)。
var ErrUnknownAction = errors.New("surface action: unknown or not allowed action id")
// ActionDispatch 由组合根注入:把动作转交命令总线(四入口同源:审计信封、
// 参数校验、DangerHigh 内联确认卡、幂等 pendingStore——全部复用,不新造)。
type ActionDispatch struct {
CommandID string
Owner string
ConversationID string
Params map[string]any
}
// ActionOutcome 是一次分发的结果(组合根从 cmdbus.Response 折叠)。
type ActionOutcome struct {
State string // awaiting_confirm | committed | rejected
CardID string
Receipt any
Payload any
}
type Dispatcher func(ctx context.Context, d ActionDispatch) (ActionOutcome, error)
// AllowedActions 是动作 id → 命令 id 的白名单(U5:C 类 submit + 经独立评审的 B 类)。
// 与 genui.AllowedActionIDs 保持同步;每加一条须 Documents 独立评审提案。
var AllowedActions = map[string]string{
"generative-ui.submit": "generative-ui.submit",
"builtin.help": "builtin.help",
"system.intent": "system.intent",
}
// SubmitActionRequest 是一次页面动作提交(按钮点击)。
type SubmitActionRequest struct {
SurfaceID core.ID
ActionID string
Revision int
RequestID string
Data map[string]any
}
// SurfaceActionResult 是动作提交结果(含确认卡关联或命令回执)。
type SurfaceActionResult struct {
SurfaceID string `json:"surfaceId"`
ActionID string `json:"actionId"`
RequestID string `json:"requestId"`
State string `json:"state"`
CardID string `json:"cardId,omitempty"`
Receipt any `json:"receipt,omitempty"`
Payload any `json:"payload,omitempty"`
Note string `json:"note,omitempty"`
}
func (s *Service) SetDispatcher(d Dispatcher) { s.dispatch = d }
// SubmitAction:启停 → revision 现势 → 白名单 → 幂等台账 → 命令总线分发。
// 本方法不接收也不透传 Confirmed:高危确认只能经既有确认卡回道置位,
// 模型或客户端伪造 Confirmed 无入口(红测断言)。
func (s *Service) SubmitAction(ctx context.Context, owner string, req SubmitActionRequest) (SurfaceActionResult, error) {
cmd, ok := AllowedActions[req.ActionID]
if !ok {
return SurfaceActionResult{}, ErrUnknownAction
}
if req.RequestID == "" {
return SurfaceActionResult{}, &ErrInvalidSpec{Errs: []string{"requestId required"}}
}
rec, err := s.st.GetSurface(owner, string(req.SurfaceID))
if err != nil {
return SurfaceActionResult{}, err
}
if act, err := s.st.GetActivation(owner, rec.PluginID); err != nil {
return SurfaceActionResult{}, err
} else if !act.Enabled {
return SurfaceActionResult{}, ErrDisabled
}
if req.Revision != rec.LatestRevision {
return SurfaceActionResult{}, &store.ErrRevisionConflict{Current: rec.LatestRevision}
}
if s.dispatch == nil {
return SurfaceActionResult{}, ErrUnavailable
}
raw, _ := json.Marshal(req.Data)
hash := fmt.Sprintf("%x", sha256.Sum256(raw))
existing, first, err := s.st.SaveSurfaceActionIfAbsent(store.SurfaceActionRecord{
Owner: owner, SurfaceID: string(req.SurfaceID), ActionID: req.ActionID,
RequestID: req.RequestID, Revision: req.Revision, ParamsHash: hash, State: "submitted",
})
if err != nil {
return SurfaceActionResult{}, err
}
if !first {
if existing.ParamsHash != hash {
return SurfaceActionResult{}, store.ErrIdempotencyConflict
}
return SurfaceActionResult{
SurfaceID: existing.SurfaceID, ActionID: existing.ActionID, RequestID: existing.RequestID,
State: existing.State, CardID: existing.CardID,
Receipt: json.RawMessage(orEmptyJSON(existing.Receipt)),
Note: "幂等重放:返回已有回执",
}, nil
}
// 参数形态:保留 data 供 generative-ui.submit;同时把 data 键上浮到顶层,
// 以便 system.intent 等命令的 ParamsSchema(required: text)能直接校验。
params := map[string]any{
"surfaceId": string(req.SurfaceID), "revision": req.Revision,
"requestId": req.RequestID, "data": req.Data,
}
for k, v := range req.Data {
if k == "surfaceId" || k == "revision" || k == "requestId" || k == "data" {
continue
}
params[k] = v
}
out, err := s.dispatch(ctx, ActionDispatch{
CommandID: cmd, Owner: owner, ConversationID: rec.ConversationID, Params: params,
})
state, cardID := out.State, out.CardID
if err != nil {
state, cardID = "rejected", ""
}
receiptJSON, _ := json.Marshal(out.Receipt)
if uerr := s.st.UpdateSurfaceActionState(owner, string(req.SurfaceID), req.ActionID, req.RequestID,
state, string(receiptJSON), cardID); uerr != nil {
s.logln("surface: action state update failed: %v", uerr)
}
if err != nil {
return SurfaceActionResult{}, err
}
note := ""
if state == "awaiting_confirm" {
note = "高危动作已发确认卡,请确认后执行"
}
return SurfaceActionResult{
SurfaceID: string(req.SurfaceID), ActionID: req.ActionID, RequestID: req.RequestID,
State: state, CardID: cardID, Receipt: out.Receipt, Payload: out.Payload, Note: note,
}, nil
}
func orEmptyJSON(s string) string {
if s == "" {
return "null"
}
return s
}
// ListByProject 项目级跨会话聚合(U6/R8):owner 授权作用域内列出项目下全部
// 页面元数据。授权口径与项目工件/卡片一致——项目属 owner,跨 owner 隔离在
// 存储层完成;禁用不阻断读取(历史页面保留,KTD7)。
func (s *Service) ListByProject(ctx context.Context, owner string, projectID core.ID) ([]core.SurfaceMeta, error) {
return s.st.ListSurfacesByProject(owner, string(projectID), 50)
}