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
365 lines
13 KiB
Go
365 lines
13 KiB
Go
// 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)
|
||
}
|