ci / vet-and-test (pull_request) Successful in 57s
Why: RDM KTD4 dual-layer admin requires base isAdmin visibility (superuser slot); KTD5 host-project binding needs trusted current-project context. Evidence: SELF 4 ext18 tests pass + surface snapshot regenerated + full pkg/extension + internal/app suites green. Impact: extension.Request gains HostProjectID; optional AdminProvider interface (EXT-17 pattern); inprocHost implements IsAdmin via GetUserAuthState; three dispatch sites set HostProjectID from conversation. Refs: RDM docs/proposals U1 base extension requests
298 lines
11 KiB
Go
298 lines
11 KiB
Go
package app
|
||
|
||
import (
|
||
"agent-system-backend/pkg/extension"
|
||
"context"
|
||
"fmt"
|
||
"sync"
|
||
|
||
"agent-system-backend/internal/ingest"
|
||
"agent-system-backend/internal/rag"
|
||
"agent-system-backend/internal/runtime"
|
||
"agent-system-backend/internal/settings"
|
||
"agent-system-backend/internal/store"
|
||
"agent-system-backend/internal/surfacesvc"
|
||
core "agent-system-core"
|
||
)
|
||
|
||
// hostcaps 聚合 owner 作用域的宿主能力(存储、检索管线、LLM 路由)。
|
||
// 同一实现同时服务 hostapi.Server 的 Search/Complete 闭包与进程内
|
||
// inprocHost(EXT-1 批注 1:复用现有 owner 作用域函数,不新造句柄)。
|
||
type hostcaps struct {
|
||
st *store.Store
|
||
pipe *rag.Pipeline
|
||
set *settings.Service
|
||
authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效)
|
||
surfaces *surfacesvc.Service // EXT-17 Surface 能力槽(nil = 未装配,明确拒绝)
|
||
}
|
||
|
||
// projectAuthz EXT-8 资源授权适配器槽:进程内扩展注册,组合根装配进 Server。
|
||
type projectAuthz struct {
|
||
mu sync.Mutex
|
||
holder string // 持有者 pluginID(首个注册者生效)
|
||
fn extension.ResourceAuthorizer
|
||
}
|
||
|
||
func (a *projectAuthz) set(pluginID string, fn extension.ResourceAuthorizer) {
|
||
a.mu.Lock()
|
||
defer a.mu.Unlock()
|
||
if a.fn != nil {
|
||
return // 首个注册者生效;后来者忽略(调用方经日志可查)
|
||
}
|
||
a.holder, a.fn = pluginID, fn
|
||
}
|
||
|
||
// Authorize owner×project 共享读取判定;未注册一律 false(默认拒绝)。
|
||
func (a *projectAuthz) Authorize(owner string, projectID core.ID) (bool, error) {
|
||
a.mu.Lock()
|
||
fn := a.fn
|
||
a.mu.Unlock()
|
||
if fn == nil || owner == "" || projectID == "" {
|
||
return false, nil
|
||
}
|
||
return fn(owner, projectID)
|
||
}
|
||
|
||
// wireScopedHostFactory EXT-11:把「按 owner+pluginID 构造受限 Host」注入公开面
|
||
// (extension.SetScopedHostFactory)。组合根在 hostcaps 就绪后调用;重建句柄与
|
||
// 分发期同构(owner 作用域、来源前缀强制、KV/任务台账隔离)。
|
||
func wireScopedHostFactory(caps hostcaps) {
|
||
extension.SetScopedHostFactory(func(owner, pluginID string) extension.Host {
|
||
return inprocHost{caps: caps, pluginID: pluginID, owner: owner}
|
||
})
|
||
}
|
||
|
||
// search owner 作用域混合检索;projectID/topK 为空/0 时用管线默认值。
|
||
// sharedRetriever EXT-13:rag.Pipeline.RetrieveShared 形状(避免 app 直接 import rag
|
||
// 的具体类型断言散落)。
|
||
type sharedRetriever interface {
|
||
RetrieveShared(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error)
|
||
}
|
||
|
||
func (c hostcaps) search(ctx context.Context, owner string, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error) {
|
||
q := core.RetrievalQuery{ProjectID: projectID, Text: text, TopK: topK}
|
||
// EXT-13:进程内 Host 检索面与回合内检索同一授权口径——适配器已注册时走
|
||
// 共享通道(本人两路如常 + 放行的他人项目知识,与 HTTP 检索面一致)。
|
||
if c.authz != nil {
|
||
if rs, ok := interface{}(c.pipe).(sharedRetriever); ok {
|
||
return rs.RetrieveShared(ctx, core.ID(owner), q, func(docOwner, pid core.ID) bool {
|
||
ok, err := c.authz.Authorize(owner, pid)
|
||
return err == nil && ok
|
||
})
|
||
}
|
||
}
|
||
return c.pipe.Retrieve(ctx, core.ID(owner), q)
|
||
}
|
||
|
||
// searchDefault 与 hostapi /host/v1 检索端点同口径(不带 projectID/topK)。
|
||
func (c hostcaps) searchDefault(ctx context.Context, owner, text string) ([]core.RetrievalHit, error) {
|
||
return c.search(ctx, owner, "", text, 0)
|
||
}
|
||
|
||
// complete 经基座 LLM 路由补全(owner 的模型配置生效;未配置报错)。
|
||
func (c hostcaps) complete(ctx context.Context, owner, prompt string) (string, error) {
|
||
route := runtime.ResolveChatModel(core.AgentProfile{}, c.set.Effective(owner, ""))
|
||
if route.Model == "" || route.Base == "" {
|
||
return "", fmt.Errorf("llm route not configured")
|
||
}
|
||
return completeChat(ctx, route.Base, route.Key, route.Model, prompt)
|
||
}
|
||
|
||
// maxParseInput 与上传端点的读取上限同口径(server.upload 的 100<<20)。
|
||
const maxParseInput = 100 << 20
|
||
|
||
// parse 走基座内置解析器(EXT-5):纯计算、不落 owner 数据;不可解析由
|
||
// Warnings 显式表达,只有取消与超限是 error。
|
||
func (c hostcaps) parse(ctx context.Context, filename, mime string, content []byte) (core.ParsedDocument, error) {
|
||
if err := ctx.Err(); err != nil {
|
||
return core.ParsedDocument{}, err
|
||
}
|
||
if len(content) > maxParseInput {
|
||
return core.ParsedDocument{}, fmt.Errorf("parse input %d bytes exceeds %d limit", len(content), maxParseInput)
|
||
}
|
||
return ingest.Parse(filename, mime, content), nil
|
||
}
|
||
|
||
// inprocHost 是进程内插件命令的 owner 作用域宿主句柄(extension.Host 实现)。
|
||
// 每次分发按请求 Owner 新建;所有能力调用都以该 owner 为第一参数,插件无法越权。
|
||
// actor 是本次分发主体(通常等于 owner);AdminProvider 能力经 actor 查询
|
||
// (EXT-18)。非 dispatch 上下文(EXT-11 scoped host)actor 为空——IsAdmin 恒 false。
|
||
type inprocHost struct {
|
||
caps hostcaps
|
||
pluginID string
|
||
owner string
|
||
actor string // EXT-18:分发主体(空=非 dispatch 上下文)
|
||
}
|
||
|
||
func (h inprocHost) PluginID() string { return h.pluginID }
|
||
func (h inprocHost) Owner() string { return h.owner }
|
||
|
||
// IsAdmin 实现 EXT-18 AdminProvider:查询本次分发主体的基座平台管理角色。
|
||
// actor 为空(非 dispatch 上下文)恒 false;store 查询失败按非管理员处理
|
||
// (fail-closed——不因存储故障放行超管面)。
|
||
func (h inprocHost) IsAdmin() bool {
|
||
if h.actor == "" {
|
||
return false
|
||
}
|
||
state, err := h.caps.st.GetUserAuthState(h.actor)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
return state.IsAdmin
|
||
}
|
||
|
||
func (h inprocHost) KVPut(key, value string) error {
|
||
return h.caps.st.PutPluginKV(h.pluginID, h.owner, key, value)
|
||
}
|
||
|
||
func (h inprocHost) KVGet(key string) (string, bool, error) {
|
||
return h.caps.st.GetPluginKV(h.pluginID, h.owner, key)
|
||
}
|
||
|
||
func (h inprocHost) Audit(action, target, evidence string) error {
|
||
return h.caps.st.AppendAuditEvent(store.AuditEvent{
|
||
Owner: h.owner, Actor: "plugin:" + h.pluginID,
|
||
Action: action, Target: target, Evidence: evidence,
|
||
})
|
||
}
|
||
|
||
func (h inprocHost) Complete(ctx context.Context, prompt string) (string, error) {
|
||
return h.caps.complete(ctx, h.owner, prompt)
|
||
}
|
||
|
||
func (h inprocHost) KnowledgeSearch(ctx context.Context, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error) {
|
||
return h.caps.search(ctx, h.owner, projectID, text, topK)
|
||
}
|
||
|
||
// KnowledgeWrite 强制 owner 归属:Owner 改写为本次分发 owner,Source 为空补
|
||
// "plugin:<id>";embed=false 只写全文行,embed=true 走 IndexChunks 建向量
|
||
// (embedding 失败降级纯全文,不阻断写入)。
|
||
func (h inprocHost) KnowledgeWrite(ctx context.Context, docs []core.KnowledgeDoc, embed bool) error {
|
||
if len(docs) == 0 {
|
||
return nil
|
||
}
|
||
src := "plugin:" + h.pluginID
|
||
for i := range docs {
|
||
docs[i].Owner = core.ID(h.owner)
|
||
if docs[i].Source == "" {
|
||
docs[i].Source = src
|
||
}
|
||
}
|
||
if !embed {
|
||
return h.caps.st.AddKnowledge(docs...)
|
||
}
|
||
return h.caps.pipe.IndexChunks(ctx, core.ID(h.owner), docs)
|
||
}
|
||
|
||
// KnowledgePublish 幂等发布(EXT-9):按 docs 的 ref 整批替换——先清该 owner
|
||
// 下同 ref 旧行与旧向量(跨来源也清:ref 是发布单元),再走 KnowledgeWrite
|
||
// 语义(Owner/Source 强制同彼)。重试产生相同结果。
|
||
func (h inprocHost) KnowledgePublish(ctx context.Context, docs []core.KnowledgeDoc, embed bool) error {
|
||
if len(docs) == 0 {
|
||
return nil
|
||
}
|
||
refs := map[string]bool{}
|
||
for i := range docs {
|
||
docs[i].Owner = core.ID(h.owner)
|
||
if docs[i].Source == "" {
|
||
docs[i].Source = "plugin:" + h.pluginID
|
||
}
|
||
if docs[i].Ref != "" {
|
||
refs[docs[i].Ref] = true
|
||
}
|
||
}
|
||
for ref := range refs {
|
||
if err := h.caps.st.DeleteKnowledgeByRef(h.owner, ref); err != nil {
|
||
return err
|
||
}
|
||
if err := h.caps.st.DeleteKnowledgeVectors(ctx, h.owner, ref); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return h.KnowledgeWrite(ctx, docs, embed)
|
||
}
|
||
|
||
// KnowledgeDelete 撤销本插件来源(EXT-9):前缀强制 plugin:<自身ID>——manual
|
||
// 或他人来源拒绝(fail-closed);删行+向量,幂等。
|
||
func (h inprocHost) KnowledgeDelete(ctx context.Context, sourcePrefix string) error {
|
||
if sourcePrefix != "plugin:"+h.pluginID {
|
||
return fmt.Errorf("ext-9: plugins may only delete their own source prefix, want %q", "plugin:"+h.pluginID)
|
||
}
|
||
_, err := h.caps.st.DeleteKnowledgeBySource(ctx, h.owner, sourcePrefix)
|
||
return err
|
||
}
|
||
|
||
// TaskCreate/TaskUpdate 后台任务台账:基座只记进度/状态,执行体由下游自跑(H5)。
|
||
func (h inprocHost) TaskCreate(kind, title, conversationID string) (string, error) {
|
||
t, err := h.caps.st.CreateBackgroundTask(h.owner, kind, title, conversationID)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return t.ID, nil
|
||
}
|
||
|
||
func (h inprocHost) TaskUpdate(taskID, status string, progress int, errText string) error {
|
||
return h.caps.st.UpdateBackgroundTask(h.owner, taskID, status, progress, errText)
|
||
}
|
||
|
||
// ArtifactGet/ArtifactContent 工件只读(R1-3 ingest 读原件;写入属完整集)。
|
||
func (h inprocHost) ArtifactGet(artifactID string) (core.Artifact, error) {
|
||
return h.caps.st.GetArtifact(h.owner, artifactID)
|
||
}
|
||
|
||
func (h inprocHost) ArtifactContent(artifactID string) ([]byte, error) {
|
||
return h.caps.st.ArtifactContent(h.owner, artifactID)
|
||
}
|
||
|
||
// ParseDocument 内置文档解析(EXT-5):委托 hostcaps.parse;纯计算不经存储,
|
||
// owner 作用域由调用方经 ArtifactContent 取字节时保证。
|
||
func (h inprocHost) ParseDocument(ctx context.Context, filename, mime string, content []byte) (core.ParsedDocument, error) {
|
||
return h.caps.parse(ctx, filename, mime, content)
|
||
}
|
||
|
||
// ---- EXT-17 Surface 能力(可选):nil 槽=能力缺失,明确拒绝不降级 ----
|
||
|
||
func (h inprocHost) surface() (*surfacesvc.Service, error) {
|
||
if h.caps.surfaces == nil {
|
||
return nil, surfacesvc.ErrUnavailable
|
||
}
|
||
return h.caps.surfaces, nil
|
||
}
|
||
|
||
func (h inprocHost) SurfaceSave(ctx context.Context, req extension.SurfaceSaveRequest) (core.SurfaceSnapshot, error) {
|
||
svc, err := h.surface()
|
||
if err != nil {
|
||
return core.SurfaceSnapshot{}, err
|
||
}
|
||
return svc.Submit(ctx, h.owner, h.pluginID, surfacesvc.SubmitRequest{
|
||
SurfaceID: req.SurfaceID, ConversationID: req.ConversationID, SourceRunID: req.SourceRunID,
|
||
BaseRevision: req.BaseRevision, ObservedEpoch: req.ObservedEpoch,
|
||
ContextVersion: req.ContextVersion, SemanticText: req.SemanticText, Spec: req.Spec,
|
||
})
|
||
}
|
||
|
||
func (h inprocHost) SurfaceGet(ctx context.Context, surfaceID core.ID) (core.SurfaceSnapshot, error) {
|
||
svc, err := h.surface()
|
||
if err != nil {
|
||
return core.SurfaceSnapshot{}, err
|
||
}
|
||
return svc.Get(ctx, h.owner, surfaceID)
|
||
}
|
||
|
||
func (h inprocHost) SurfaceList(ctx context.Context, conversationID core.ID) ([]core.SurfaceMeta, error) {
|
||
svc, err := h.surface()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return svc.List(ctx, h.owner, conversationID)
|
||
}
|
||
|
||
func (h inprocHost) SurfaceActivation(ctx context.Context) (bool, int64, error) {
|
||
svc, err := h.surface()
|
||
if err != nil {
|
||
return false, 0, err
|
||
}
|
||
row, err := svc.Activation(ctx, h.owner, h.pluginID)
|
||
return row.Enabled, row.Epoch, err
|
||
}
|