Files
WeKnora/internal/plugin/activate/invoke.go
T
lyingbug ff49f9491d feat(plugin): Host API with a key-value store, and document parsers from plugins (#3721)
* feat(plugin): Host API with plugin tokens and a key-value store
* feat(plugin): document parsers from code plugins
* docs(pluginsdk): parsers and the Host API
2026-09-26 14:45:39 +08:00

177 lines
5.2 KiB
Go

package activate
import (
"context"
"sync"
"time"
"github.com/Tencent/WeKnora/internal/plugin/install"
"github.com/Tencent/WeKnora/internal/plugin/manifest"
"github.com/Tencent/WeKnora/internal/plugin/tenancy"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/Tencent/WeKnora/pluginsdk/client"
"github.com/Tencent/WeKnora/pluginsdk/pluginapi"
)
// ClientSource reaches running code plugins (the node's plugin host).
type ClientSource interface {
Client(pluginID string) (*client.Client, error)
}
// Invoker is how the remote adapters call a code plugin: it finds the
// process and builds the protocol envelope from the request context and the
// plugin's decrypted system and tenant configuration.
type Invoker struct {
clients ClientSource
mu sync.RWMutex
tenancy *tenancy.Service
plugins interfaces.PluginRepository
// tokens and hostURL give plugins granted Host API scopes a way back.
tokens TokenIssuer
hostURL string
}
// TokenIssuer signs the Host API token of one call.
type TokenIssuer interface {
Issue(pluginID, version string, tenantID uint64, scopes []string) (string, time.Time, error)
}
// SetHostAPI lets calls carry Host API access: where plugins reach WeKnora
// and how their tokens are signed.
func (iv *Invoker) SetHostAPI(tokens TokenIssuer, url string) {
iv.mu.Lock()
iv.tokens, iv.hostURL = tokens, url
iv.mu.Unlock()
}
// NewInvoker creates an Invoker; Bind completes it.
func NewInvoker(clients ClientSource) *Invoker { return &Invoker{clients: clients} }
// Bind supplies the tenant configuration and installed plugin rows.
func (iv *Invoker) Bind(t *tenancy.Service, plugins interfaces.PluginRepository) {
iv.mu.Lock()
iv.tenancy, iv.plugins = t, plugins
iv.mu.Unlock()
}
// Default timeouts per call kind, used when the caller set no deadline.
const (
searchTimeout = 10 * time.Second
metadataTimeout = time.Minute
)
// withDefaultTimeout bounds ctx unless it already has a deadline.
func withDefaultTimeout(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) {
if _, ok := ctx.Deadline(); ok {
return context.WithCancel(ctx)
}
return context.WithTimeout(ctx, d)
}
// Envelope builds the call envelope for a plugin with an instance config.
func (iv *Invoker) Envelope(
ctx context.Context,
m *manifest.Manifest,
instance map[string]any,
) (pluginapi.Envelope, error) {
env := pluginapi.Envelope{Config: pluginapi.Config{Instance: instance}}
if tenantID, ok := types.TenantIDFromContext(ctx); ok {
env.Context.TenantID = tenantID
}
if userID, ok := ctx.Value(types.UserIDContextKey).(string); ok {
env.Context.UserID = userID
}
if lang, ok := types.LanguageFromContext(ctx); ok {
env.Context.Locale = lang
}
if rid, ok := ctx.Value(types.RequestIDContextKey).(string); ok {
env.Context.RequestID = rid
}
if d, ok := ctx.Deadline(); ok {
env.Context.Deadline = &d
}
iv.mu.RLock()
t, plugins, tokens, hostURL := iv.tenancy, iv.plugins, iv.tokens, iv.hostURL
iv.mu.RUnlock()
if tokens != nil && hostURL != "" && env.Context.TenantID != 0 && len(m.Permissions.HostAPI) > 0 {
token, _, err := tokens.Issue(m.ID, m.Version, env.Context.TenantID, m.Permissions.HostAPI)
if err != nil {
return env, err
}
env.Context.Host = &pluginapi.HostAccess{URL: hostURL, Token: token}
}
if plugins != nil && len(m.Config.SystemSchema) > 0 {
sys, _, err := install.OpenSystemConfig(ctx, plugins, m)
if err != nil {
return env, err
}
env.Config.System = sys
}
if t != nil && env.Context.TenantID != 0 && len(m.Config.TenantSchema) > 0 {
cfg, _, err := t.OpenConfig(ctx, env.Context.TenantID, m.ID)
if err != nil {
return env, err
}
env.Config.Tenant = cfg
}
return env, nil
}
// callError presents a plugin's error by its message, the way WeKnora's own
// errors read in the UI; the protocol code stays reachable through
// errors.As(err, **pluginapi.Error).
type callError struct {
pluginID string
err *pluginapi.Error
}
func (e *callError) Error() string {
if e.err.Code == pluginapi.CodeUnavailable {
return "plugin " + e.pluginID + " is unavailable: " + e.err.Message
}
return e.err.Message
}
func (e *callError) Unwrap() error { return e.err }
func present(pluginID string, err error) error {
if pe, ok := pluginapi.AsError(err); ok {
return &callError{pluginID: pluginID, err: pe}
}
return err
}
// Call makes a unary call to a plugin.
func (iv *Invoker) Call(
ctx context.Context, m *manifest.Manifest, path string, instance map[string]any, input, out any,
) error {
c, err := iv.clients.Client(m.ID)
if err != nil {
return present(m.ID, err)
}
env, err := iv.Envelope(ctx, m, instance)
if err != nil {
return err
}
return present(m.ID, c.Call(ctx, path, env, input, out))
}
// Stream makes a streaming call to a plugin.
func (iv *Invoker) Stream(
ctx context.Context, m *manifest.Manifest, path string, instance map[string]any, input any,
fn func(pluginapi.Event) error,
) ([]byte, error) {
c, err := iv.clients.Client(m.ID)
if err != nil {
return nil, present(m.ID, err)
}
env, err := iv.Envelope(ctx, m, instance)
if err != nil {
return nil, err
}
end, err := c.Stream(ctx, path, env, input, fn)
return end, present(m.ID, err)
}