755 lines
27 KiB
Go
755 lines
27 KiB
Go
package wasmguest
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"sort"
|
|
"time"
|
|
|
|
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
|
|
"git.dev.alexdunmow.com/block/pluginsdk/ai"
|
|
"git.dev.alexdunmow.com/block/pluginsdk/blocks"
|
|
"git.dev.alexdunmow.com/block/pluginsdk/plugin"
|
|
"git.dev.alexdunmow.com/block/pluginsdk/plugin/wasmguest/bnwasm"
|
|
"git.dev.alexdunmow.com/block/pluginsdk/plugin/wasmguest/caps"
|
|
"git.dev.alexdunmow.com/block/pluginsdk/templates"
|
|
"github.com/google/uuid"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
// capTransport is the guest→host capability transport handed to the
|
|
// capability stubs (package caps). hostcalls.go (wasip1) binds it to CallHost in an init;
|
|
// on native builds it stays nil, so capability calls report "no host
|
|
// transport" rather than reaching a boundary that isn't there. Tests exercise
|
|
// the stubs directly with their own fake transport.
|
|
var capTransport caps.CallFunc
|
|
|
|
// current is the guest runtime installed by Serve. The bn_invoke export
|
|
// (exports.go, wasip1) dispatches through it.
|
|
var current *guest
|
|
|
|
// Serve installs a plugin registration as the wasm guest runtime. It is
|
|
// non-blocking: plugins call it from an init func (see doc.go) so it runs
|
|
// during the module's `_initialize`; all work then arrives via bn_invoke.
|
|
func Serve(reg plugin.PluginRegistration) {
|
|
current = newGuest(reg)
|
|
}
|
|
|
|
// HostServices returns the CoreServices bound to THIS guest instance. Unlike
|
|
// the deps handed to Load/HTTPHandler/JobHandlers — which the host delivers on
|
|
// only one pooled instance (Load) or lazily on first use — these services are
|
|
// bound at `_initialize` on EVERY pooled instance (newGuest). The interface
|
|
// fields are host_call-backed capability stubs and Pool is a live db.* bridge.
|
|
//
|
|
// This is the escape hatch for entry points the ABI does not hand deps to —
|
|
// most importantly block/template render funcs (HOOK_RENDER_BLOCK gives the
|
|
// closure only ctx+content, no services). A DB-backed block reads its pool from
|
|
// HostServices().Pool so it works on any pooled instance, not just the one that
|
|
// happened to run Load. In a native build (Serve never called — no wasip1
|
|
// _initialize) current is nil and this returns the zero CoreServices, so guest
|
|
// code must nil-check Pool (native tests inject a real pool by other means).
|
|
func HostServices() plugin.CoreServices {
|
|
if current == nil {
|
|
return plugin.CoreServices{}
|
|
}
|
|
return current.services
|
|
}
|
|
|
|
// guest adapts a plugin.PluginRegistration to ABI hook calls.
|
|
type guest struct {
|
|
reg plugin.PluginRegistration
|
|
blocks *captureBlockRegistry
|
|
templates *captureTemplateRegistry
|
|
registerErr error
|
|
|
|
// services is the CoreServices value handed to the registration's
|
|
// function fields (HTTPHandler, JobHandlers, Load). Its interface fields
|
|
// are caps host_call-backed stubs (package caps); LOAD fills
|
|
// AppURL/MediaPath from HostConfig.
|
|
services plugin.CoreServices
|
|
|
|
// rag is the guest-side RAG stub inside services. It records
|
|
// RegisterContentFetcher calls (dispatched via HOOK_RAG_FETCH) while its
|
|
// Query/OnContentChanged marshal out as capability calls.
|
|
rag *caps.RAGStub
|
|
|
|
jobHandlers map[string]plugin.JobHandlerFunc
|
|
jobHandlersInit bool
|
|
httpHandler http.Handler
|
|
httpHandlerInit bool
|
|
}
|
|
|
|
// newGuest captures the registration's Register pass so block/template
|
|
// functions are addressable before any hook fires.
|
|
func newGuest(reg plugin.PluginRegistration) *guest {
|
|
g := &guest{
|
|
reg: reg,
|
|
blocks: newCaptureBlockRegistry(),
|
|
templates: newCaptureTemplateRegistry(),
|
|
}
|
|
g.services = caps.NewCoreServices(capTransport)
|
|
// Pool is left zero by NewCoreServices (it does not cross as a capability
|
|
// call); bind the db.* driver over the same transport so deps.Pool keeps
|
|
// working for plugin sqlc code. A nil transport (native/DESCRIBE) yields a
|
|
// Pool whose calls fail cleanly, matching the capability stubs.
|
|
g.services.Pool = bnwasm.NewPool(bnwasm.Transport(capTransport))
|
|
g.rag, _ = g.services.RAGService.(*caps.RAGStub)
|
|
g.registerErr = runRegister(reg, g.templates, g.blocks)
|
|
return g
|
|
}
|
|
|
|
// runRegister invokes Register (or RegisterWithProvisioner with a no-op
|
|
// provisioner) against capture registries, converting panics to errors.
|
|
// Register-time provisioning cannot cross the ABI (DESCRIBE stubs every host
|
|
// function to fail), so RegisterWithProvisioner's provisioner is inert here;
|
|
// wasm plugins provision from Load via deps.Provisioner (the provisioner.*
|
|
// capability family, WO-WZ-019) instead.
|
|
func runRegister(reg plugin.PluginRegistration, tr *captureTemplateRegistry, br *captureBlockRegistry) (err error) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
err = fmt.Errorf("panic in Register: %v", r)
|
|
}
|
|
}()
|
|
// Give this registration a clean tag/filter slate. blocks.RegisterTag /
|
|
// RegisterFilter write a package-level registry (one plugin owns the wasm
|
|
// module), so resetting here makes newGuest deterministic: after Register,
|
|
// the registry holds exactly this plugin's tags/filters for DESCRIBE and
|
|
// HOOK_RENDER_TAG / HOOK_APPLY_FILTER dispatch.
|
|
blocks.ResetRegisteredTagsAndFilters()
|
|
// Match the .so loader: block keys are plugin-prefixed and override
|
|
// sources stamped via PluginBlockRegistry.
|
|
pbr := plugin.NewPluginBlockRegistry(br, reg.Name)
|
|
switch {
|
|
case reg.Register != nil:
|
|
return reg.Register(tr, pbr)
|
|
case reg.RegisterWithProvisioner != nil:
|
|
return reg.RegisterWithProvisioner(tr, pbr, noopProvisioner{})
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// invoke handles one bn_invoke call: (hook id, serialized InvokeRequest) →
|
|
// serialized InvokeResponse. It never panics — plugin-level panics come back
|
|
// as ABI_ERROR_CODE_INTERNAL so the instance stays usable; traps are
|
|
// reserved for runtime corruption.
|
|
func (g *guest) invoke(hookID uint32, req []byte) (out []byte) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
out = errorResponse(abiv1.AbiErrorCode_ABI_ERROR_CODE_INTERNAL, fmt.Sprintf("panic: %v", r))
|
|
}
|
|
}()
|
|
|
|
env := &abiv1.InvokeRequest{}
|
|
if err := proto.Unmarshal(req, env); err != nil {
|
|
return errorResponse(abiv1.AbiErrorCode_ABI_ERROR_CODE_DECODE, "invalid InvokeRequest: "+err.Error())
|
|
}
|
|
if uint32(env.GetHook()) != hookID {
|
|
return errorResponse(abiv1.AbiErrorCode_ABI_ERROR_CODE_DECODE,
|
|
fmt.Sprintf("hook id mismatch: export arg %d, envelope %d", hookID, env.GetHook()))
|
|
}
|
|
if g.registerErr != nil {
|
|
return errorResponse(abiv1.AbiErrorCode_ABI_ERROR_CODE_INTERNAL, "plugin Register failed: "+g.registerErr.Error())
|
|
}
|
|
|
|
ctx := context.Background()
|
|
if d := env.GetDeadlineMs(); d > 0 {
|
|
var cancel context.CancelFunc
|
|
ctx, cancel = context.WithTimeout(ctx, time.Duration(d)*time.Millisecond)
|
|
defer cancel()
|
|
}
|
|
|
|
var (
|
|
payload proto.Message
|
|
abiErr *abiv1.AbiError
|
|
)
|
|
switch env.GetHook() {
|
|
case abiv1.Hook_HOOK_DESCRIBE:
|
|
payload, abiErr = g.describe(env.GetPayload())
|
|
case abiv1.Hook_HOOK_RENDER_BLOCK:
|
|
payload, abiErr = g.renderBlock(ctx, env.GetPayload())
|
|
case abiv1.Hook_HOOK_RENDER_TEMPLATE:
|
|
payload, abiErr = g.renderTemplate(ctx, env.GetPayload())
|
|
case abiv1.Hook_HOOK_RENDER_TAG:
|
|
payload, abiErr = g.renderTag(ctx, env.GetPayload())
|
|
case abiv1.Hook_HOOK_APPLY_FILTER:
|
|
payload, abiErr = g.applyFilter(ctx, env.GetPayload())
|
|
case abiv1.Hook_HOOK_HANDLE_HTTP:
|
|
payload, abiErr = g.handleHTTP(ctx, env.GetPayload())
|
|
case abiv1.Hook_HOOK_JOB:
|
|
payload, abiErr = g.runJob(ctx, env.GetPayload())
|
|
case abiv1.Hook_HOOK_LOAD:
|
|
payload, abiErr = g.load(env.GetPayload())
|
|
case abiv1.Hook_HOOK_UNLOAD:
|
|
payload, abiErr = g.unload(ctx)
|
|
case abiv1.Hook_HOOK_RAG_FETCH:
|
|
payload, abiErr = g.ragFetch(ctx, env.GetPayload())
|
|
case abiv1.Hook_HOOK_MEDIA_HOOK:
|
|
payload, abiErr = g.mediaHook(ctx, env.GetPayload())
|
|
case abiv1.Hook_HOOK_AI_TOOL_CALL:
|
|
payload, abiErr = g.aiToolCall(ctx, env.GetPayload())
|
|
case abiv1.Hook_HOOK_BRIDGE_CALL:
|
|
payload, abiErr = g.bridgeCall(ctx, env.GetPayload())
|
|
case abiv1.Hook_HOOK_DIRECTORY_PANEL_SECTION:
|
|
payload, abiErr = g.directoryPanelSection(env.GetPayload())
|
|
case abiv1.Hook_HOOK_DIRECTORY_PIN_DECORATOR:
|
|
payload, abiErr = g.directoryPinDecorator(env.GetPayload())
|
|
default:
|
|
abiErr = &abiv1.AbiError{
|
|
Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED,
|
|
Message: fmt.Sprintf("unknown hook %d", env.GetHook()),
|
|
}
|
|
}
|
|
|
|
resp := &abiv1.InvokeResponse{Error: abiErr}
|
|
if abiErr == nil && payload != nil {
|
|
b, err := proto.Marshal(payload)
|
|
if err != nil {
|
|
return errorResponse(abiv1.AbiErrorCode_ABI_ERROR_CODE_INTERNAL, "marshal response payload: "+err.Error())
|
|
}
|
|
resp.Payload = b
|
|
}
|
|
b, err := proto.Marshal(resp)
|
|
if err != nil {
|
|
return errorResponse(abiv1.AbiErrorCode_ABI_ERROR_CODE_INTERNAL, "marshal InvokeResponse: "+err.Error())
|
|
}
|
|
return b
|
|
}
|
|
|
|
// --- hook handlers ---
|
|
|
|
func (g *guest) renderBlock(ctx context.Context, payload []byte) (proto.Message, *abiv1.AbiError) {
|
|
req := &abiv1.RenderBlockRequest{}
|
|
if err := proto.Unmarshal(payload, req); err != nil {
|
|
return nil, decodeError("RenderBlockRequest", err)
|
|
}
|
|
fn, ok := g.blocks.lookup(req.GetBlockKey(), req.GetRenderContext().GetTemplateKey())
|
|
if !ok {
|
|
return nil, &abiv1.AbiError{
|
|
Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED,
|
|
Message: "unknown block key " + req.GetBlockKey(),
|
|
}
|
|
}
|
|
ctx = contextFromRenderContext(ctx, req.GetRenderContext())
|
|
content := unmarshalMap(req.GetContentJson())
|
|
if content == nil {
|
|
content = map[string]any{}
|
|
}
|
|
out := fn(ctx, content)
|
|
// A block may return EITHER final HTML or a "powered" result
|
|
// (blocks.PoweredBlock): a template + data the HOST renders with pongo2
|
|
// after this call returns. Detect the powered marker and forward it as
|
|
// PoweredBlock; otherwise it is plain HTML.
|
|
if pr, ok := blocks.DecodePoweredBlock(out); ok {
|
|
dataJSON, err := json.Marshal(pr.Data)
|
|
if err != nil {
|
|
return nil, internalError("marshal powered block data: " + err.Error())
|
|
}
|
|
return &abiv1.RenderBlockResponse{
|
|
Powered: &abiv1.PoweredBlock{Template: pr.Template, DataJson: dataJSON},
|
|
}, nil
|
|
}
|
|
return &abiv1.RenderBlockResponse{Html: out}, nil
|
|
}
|
|
|
|
// renderTag handles HOOK_RENDER_TAG: the host engine hit a plugin-declared
|
|
// tag while rendering a powered block and calls back to run it. Re-entrancy-
|
|
// free — the originating RENDER_BLOCK has already returned, so this is a fresh
|
|
// invoke. A tag fn error surfaces in RenderTagResponse.error (not an AbiError,
|
|
// which stays reserved for decode/dispatch/panic failures).
|
|
func (g *guest) renderTag(ctx context.Context, payload []byte) (proto.Message, *abiv1.AbiError) {
|
|
req := &abiv1.RenderTagRequest{}
|
|
if err := proto.Unmarshal(payload, req); err != nil {
|
|
return nil, decodeError("RenderTagRequest", err)
|
|
}
|
|
fn, ok := blocks.LookupTag(req.GetTagName())
|
|
if !ok {
|
|
return nil, &abiv1.AbiError{
|
|
Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED,
|
|
Message: "unknown tag " + req.GetTagName(),
|
|
}
|
|
}
|
|
ctx = contextFromRenderContext(ctx, req.GetRenderContext())
|
|
args := unmarshalMap(req.GetArgsJson())
|
|
if args == nil {
|
|
args = map[string]any{}
|
|
}
|
|
html, err := fn(args, ctx)
|
|
if err != nil {
|
|
return &abiv1.RenderTagResponse{Error: err.Error()}, nil
|
|
}
|
|
return &abiv1.RenderTagResponse{Html: html}, nil
|
|
}
|
|
|
|
// applyFilter handles HOOK_APPLY_FILTER: run a plugin-declared filter. Same
|
|
// re-entrancy-free callback model as renderTag.
|
|
func (g *guest) applyFilter(_ context.Context, payload []byte) (proto.Message, *abiv1.AbiError) {
|
|
req := &abiv1.ApplyFilterRequest{}
|
|
if err := proto.Unmarshal(payload, req); err != nil {
|
|
return nil, decodeError("ApplyFilterRequest", err)
|
|
}
|
|
fn, ok := blocks.LookupFilter(req.GetFilterName())
|
|
if !ok {
|
|
return nil, &abiv1.AbiError{
|
|
Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED,
|
|
Message: "unknown filter " + req.GetFilterName(),
|
|
}
|
|
}
|
|
args := unmarshalMap(req.GetArgsJson())
|
|
if args == nil {
|
|
args = map[string]any{}
|
|
}
|
|
out, err := fn(req.GetInput(), args)
|
|
if err != nil {
|
|
return &abiv1.ApplyFilterResponse{Error: err.Error()}, nil
|
|
}
|
|
return &abiv1.ApplyFilterResponse{Output: out}, nil
|
|
}
|
|
|
|
func (g *guest) renderTemplate(ctx context.Context, payload []byte) (proto.Message, *abiv1.AbiError) {
|
|
req := &abiv1.RenderTemplateRequest{}
|
|
if err := proto.Unmarshal(payload, req); err != nil {
|
|
return nil, decodeError("RenderTemplateRequest", err)
|
|
}
|
|
fn, ok := g.templates.lookup(req.GetTemplateKey())
|
|
if !ok {
|
|
return nil, &abiv1.AbiError{
|
|
Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED,
|
|
Message: "unknown template key " + req.GetTemplateKey(),
|
|
}
|
|
}
|
|
ctx = contextFromRenderContext(ctx, req.GetRenderContext())
|
|
doc := unmarshalMap(req.GetDocJson())
|
|
if doc == nil {
|
|
doc = map[string]any{}
|
|
}
|
|
component := fn(ctx, doc)
|
|
if component == nil {
|
|
return nil, internalError("template " + req.GetTemplateKey() + " returned nil component")
|
|
}
|
|
tdoc := &abiv1.TemplateDocument{}
|
|
renderPart := func(c templates.HTMLComponent) ([]byte, error) {
|
|
if c == nil {
|
|
return nil, nil
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := c.Render(ctx, &buf); err != nil {
|
|
return nil, err
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
if pd, ok := component.(*templates.PageDocument); ok {
|
|
body, err := renderPart(pd.Body)
|
|
if err != nil {
|
|
return nil, internalError("render template " + req.GetTemplateKey() + ": " + err.Error())
|
|
}
|
|
head, err := renderPart(pd.HeadExtra)
|
|
if err != nil {
|
|
return nil, internalError("render template " + req.GetTemplateKey() + " head extra: " + err.Error())
|
|
}
|
|
end, err := renderPart(pd.BodyEndExtra)
|
|
if err != nil {
|
|
return nil, internalError("render template " + req.GetTemplateKey() + " body-end extra: " + err.Error())
|
|
}
|
|
tdoc.BodyHtml = body
|
|
tdoc.BodyClass = pd.BodyClass
|
|
tdoc.Lang = pd.Lang
|
|
tdoc.HtmlAttrs = pd.HTMLAttrs
|
|
tdoc.HeadExtraHtml = head
|
|
tdoc.BodyEndExtraHtml = end
|
|
} else {
|
|
body, err := renderPart(component)
|
|
if err != nil {
|
|
return nil, internalError("render template " + req.GetTemplateKey() + ": " + err.Error())
|
|
}
|
|
tdoc.BodyHtml = body
|
|
}
|
|
return &abiv1.RenderTemplateResponse{Document: tdoc}, nil
|
|
}
|
|
|
|
func (g *guest) handleHTTP(ctx context.Context, payload []byte) (proto.Message, *abiv1.AbiError) {
|
|
if g.reg.HTTPHandler == nil {
|
|
return nil, &abiv1.AbiError{
|
|
Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED,
|
|
Message: "plugin has no HTTP handler",
|
|
}
|
|
}
|
|
req := &abiv1.HttpRequest{}
|
|
if err := proto.Unmarshal(payload, req); err != nil {
|
|
return nil, decodeError("HttpRequest", err)
|
|
}
|
|
if !g.httpHandlerInit {
|
|
g.httpHandler = g.reg.HTTPHandler(g.services)
|
|
g.httpHandlerInit = true
|
|
}
|
|
if g.httpHandler == nil {
|
|
return nil, internalError("HTTPHandler returned nil handler")
|
|
}
|
|
|
|
httpReq, err := http.NewRequestWithContext(ctx, req.GetMethod(),
|
|
(&url.URL{Path: req.GetPath(), RawQuery: req.GetRawQuery()}).String(),
|
|
bytes.NewReader(req.GetBody()))
|
|
if err != nil {
|
|
return nil, internalError("build http request: " + err.Error())
|
|
}
|
|
for name, hv := range req.GetHeaders() {
|
|
for _, v := range hv.GetValues() {
|
|
httpReq.Header.Add(name, v)
|
|
}
|
|
}
|
|
|
|
rec := httptest.NewRecorder()
|
|
g.httpHandler.ServeHTTP(rec, httpReq)
|
|
res := rec.Result()
|
|
|
|
out := &abiv1.HttpResponse{
|
|
Status: int32(res.StatusCode),
|
|
Headers: make(map[string]*abiv1.HeaderValues, len(res.Header)),
|
|
Body: rec.Body.Bytes(),
|
|
}
|
|
for name, values := range res.Header {
|
|
out.Headers[name] = &abiv1.HeaderValues{Values: values}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (g *guest) runJob(ctx context.Context, payload []byte) (proto.Message, *abiv1.AbiError) {
|
|
req := &abiv1.JobRequest{}
|
|
if err := proto.Unmarshal(payload, req); err != nil {
|
|
return nil, decodeError("JobRequest", err)
|
|
}
|
|
if !g.jobHandlersInit {
|
|
if g.reg.JobHandlers != nil {
|
|
g.jobHandlers = g.reg.JobHandlers(g.services)
|
|
}
|
|
g.jobHandlersInit = true
|
|
}
|
|
handler, ok := g.jobHandlers[req.GetJobType()]
|
|
if !ok {
|
|
return nil, &abiv1.AbiError{
|
|
Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED,
|
|
Message: "unknown job type " + req.GetJobType(),
|
|
}
|
|
}
|
|
// Progress crosses as best-effort jobs.progress host calls; the host
|
|
// correlates them to the running job via the HOOK_JOB call's context. A
|
|
// nil transport (native builds) discards updates.
|
|
progress := func(current, total int, message string) {
|
|
if capTransport == nil {
|
|
return
|
|
}
|
|
preq := &abiv1.JobsProgressRequest{Current: int32(current), Total: int32(total), Message: message}
|
|
_ = capTransport("jobs.progress", preq, &abiv1.JobsProgressResponse{})
|
|
}
|
|
result, err := handler(ctx, req.GetConfigJson(), progress)
|
|
if err != nil {
|
|
return nil, internalError(err.Error())
|
|
}
|
|
return &abiv1.JobResponse{ResultJson: result}, nil
|
|
}
|
|
|
|
func (g *guest) load(payload []byte) (proto.Message, *abiv1.AbiError) {
|
|
req := &abiv1.LoadRequest{}
|
|
if err := proto.Unmarshal(payload, req); err != nil {
|
|
return nil, decodeError("LoadRequest", err)
|
|
}
|
|
if hc := req.GetHostConfig(); hc != nil {
|
|
g.services.AppURL = hc.GetAppUrl()
|
|
g.services.MediaPath = hc.GetMediaPath()
|
|
}
|
|
if g.reg.Load != nil {
|
|
if err := g.reg.Load(g.services); err != nil {
|
|
return nil, internalError(err.Error())
|
|
}
|
|
}
|
|
return &abiv1.LoadResponse{}, nil
|
|
}
|
|
|
|
func (g *guest) unload(ctx context.Context) (proto.Message, *abiv1.AbiError) {
|
|
if g.reg.Unload != nil {
|
|
if err := g.reg.Unload(ctx); err != nil {
|
|
return nil, internalError(err.Error())
|
|
}
|
|
}
|
|
return &abiv1.UnloadResponse{}, nil
|
|
}
|
|
|
|
func (g *guest) ragFetch(ctx context.Context, payload []byte) (proto.Message, *abiv1.AbiError) {
|
|
req := &abiv1.RagFetchRequest{}
|
|
if err := proto.Unmarshal(payload, req); err != nil {
|
|
return nil, decodeError("RagFetchRequest", err)
|
|
}
|
|
fetcher, ok := g.ragFetcher(req.GetContentType())
|
|
if !ok {
|
|
return nil, &abiv1.AbiError{
|
|
Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED,
|
|
Message: "no content fetcher for type " + req.GetContentType(),
|
|
}
|
|
}
|
|
contentID, err := uuid.Parse(req.GetContentId())
|
|
if err != nil {
|
|
return nil, decodeError("RagFetchRequest.content_id", err)
|
|
}
|
|
title, text, err := fetcher(ctx, contentID)
|
|
if err != nil {
|
|
return nil, internalError(err.Error())
|
|
}
|
|
return &abiv1.RagFetchResponse{Title: title, Text: text}, nil
|
|
}
|
|
|
|
func (g *guest) mediaHook(ctx context.Context, payload []byte) (proto.Message, *abiv1.AbiError) {
|
|
if g.reg.MediaHooks == nil {
|
|
return nil, &abiv1.AbiError{
|
|
Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED,
|
|
Message: "plugin has no media hooks",
|
|
}
|
|
}
|
|
req := &abiv1.MediaHookRequest{}
|
|
if err := proto.Unmarshal(payload, req); err != nil {
|
|
return nil, decodeError("MediaHookRequest", err)
|
|
}
|
|
var err error
|
|
switch ev := req.GetEvent().(type) {
|
|
case *abiv1.MediaHookRequest_MediaAnalyzed:
|
|
e := ev.MediaAnalyzed
|
|
err = g.reg.MediaHooks.OnMediaAnalyzed(ctx, plugin.MediaAnalyzedEvent{
|
|
MediaID: parseUUID(e.GetMediaId()),
|
|
AnalysisID: parseUUID(e.GetAnalysisId()),
|
|
ContentHash: e.GetContentHash(),
|
|
Status: e.GetStatus(),
|
|
SourcePlugin: e.GetSourcePlugin(),
|
|
SourceType: e.GetSourceType(),
|
|
SourceRefID: parseUUID(e.GetSourceRefId()),
|
|
SafeAdult: e.GetSafeAdult(),
|
|
SafeViolence: e.GetSafeViolence(),
|
|
SafeRacy: e.GetSafeRacy(),
|
|
})
|
|
case *abiv1.MediaHookRequest_ModerationDecision:
|
|
e := ev.ModerationDecision
|
|
err = g.reg.MediaHooks.OnModerationDecision(ctx, plugin.ModerationDecisionEvent{
|
|
MediaID: parseUUID(e.GetMediaId()),
|
|
AnalysisID: parseUUID(e.GetAnalysisId()),
|
|
Status: e.GetStatus(),
|
|
PreviousStatus: e.GetPreviousStatus(),
|
|
SourcePlugin: e.GetSourcePlugin(),
|
|
SourceType: e.GetSourceType(),
|
|
SourceRefID: parseUUID(e.GetSourceRefId()),
|
|
ModeratedBy: parseUUID(e.GetModeratedBy()),
|
|
Note: e.GetNote(),
|
|
})
|
|
default:
|
|
return nil, decodeError("MediaHookRequest.event", fmt.Errorf("empty oneof"))
|
|
}
|
|
if err != nil {
|
|
return nil, internalError(err.Error())
|
|
}
|
|
return &abiv1.MediaHookResponse{}, nil
|
|
}
|
|
|
|
// --- error helpers ---
|
|
|
|
func internalError(msg string) *abiv1.AbiError {
|
|
return &abiv1.AbiError{Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_INTERNAL, Message: msg}
|
|
}
|
|
|
|
func decodeError(what string, err error) *abiv1.AbiError {
|
|
return &abiv1.AbiError{
|
|
Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_DECODE,
|
|
Message: "decode " + what + ": " + err.Error(),
|
|
}
|
|
}
|
|
|
|
// errorResponse builds a serialized error-only InvokeResponse. Marshaling a
|
|
// flat AbiError cannot fail; if the runtime is corrupted enough that it
|
|
// does, returning nil makes bn_invoke return packed 0, which the host treats
|
|
// as INTERNAL + instance discard (per wasm-abi.md).
|
|
func errorResponse(code abiv1.AbiErrorCode, msg string) []byte {
|
|
b, err := proto.Marshal(&abiv1.InvokeResponse{Error: &abiv1.AbiError{Code: code, Message: msg}})
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return b
|
|
}
|
|
|
|
// ragFetcher returns the content fetcher the plugin registered for a content
|
|
// type (via RAGService.RegisterContentFetcher during LOAD), for
|
|
// HOOK_RAG_FETCH dispatch. Nil-safe when the guest has no RAG stub.
|
|
func (g *guest) ragFetcher(contentType string) (plugin.ContentFetcher, bool) {
|
|
if g.rag == nil {
|
|
return nil, false
|
|
}
|
|
return g.rag.Fetcher(contentType)
|
|
}
|
|
|
|
// toolLookup / serviceLookup are the recording-stub accessors dispatch needs
|
|
// for HOOK_AI_TOOL_CALL / HOOK_BRIDGE_CALL. The caps stubs implement them;
|
|
// asserting interfaces keeps the stub types unexported.
|
|
type toolLookup interface {
|
|
Tool(slug string) (*ai.ToolDefinition, bool)
|
|
}
|
|
|
|
type serviceLookup interface {
|
|
Service(serviceName string) (any, bool)
|
|
}
|
|
|
|
// aiToolCall executes a guest-registered AI tool handler (HOOK_AI_TOOL_CALL).
|
|
// Tools must be registered in Register (every pooled instance runs it) for the
|
|
// lookup to succeed on any instance — see wasm-abi.md §"Per-instance state".
|
|
func (g *guest) aiToolCall(ctx context.Context, payload []byte) (proto.Message, *abiv1.AbiError) {
|
|
req := &abiv1.AiToolCallRequest{}
|
|
if err := proto.Unmarshal(payload, req); err != nil {
|
|
return nil, decodeError("AiToolCallRequest", err)
|
|
}
|
|
reg, ok := g.services.ToolRegistry.(toolLookup)
|
|
if !ok {
|
|
return nil, &abiv1.AbiError{
|
|
Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED,
|
|
Message: "tool registry does not record handlers",
|
|
}
|
|
}
|
|
tool, ok := reg.Tool(req.GetSlug())
|
|
if !ok || tool.Handler == nil {
|
|
return nil, &abiv1.AbiError{
|
|
Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED,
|
|
Message: "no handler for AI tool " + req.GetSlug() + " on this instance (register tools in Register, not Load)",
|
|
}
|
|
}
|
|
var params map[string]any
|
|
if raw := req.GetParamsJson(); len(raw) > 0 {
|
|
if err := json.Unmarshal(raw, ¶ms); err != nil {
|
|
return nil, decodeError("AiToolCallRequest.params_json", err)
|
|
}
|
|
}
|
|
result, err := tool.Handler(ctx, params)
|
|
if err != nil {
|
|
return nil, internalError(err.Error())
|
|
}
|
|
resp := &abiv1.AiToolCallResponse{}
|
|
if result != nil {
|
|
resp.Content = result.Content
|
|
resp.ErrorMessage = result.Error
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// bridgeCall invokes a method on one of THIS plugin's registered bridge
|
|
// services (HOOK_BRIDGE_CALL; the consumer side is the bridge.invoke
|
|
// capability). The service value must implement plugin.BridgeInvokable and be
|
|
// registered in Register so every pooled instance has it.
|
|
func (g *guest) bridgeCall(ctx context.Context, payload []byte) (proto.Message, *abiv1.AbiError) {
|
|
req := &abiv1.BridgeCallRequest{}
|
|
if err := proto.Unmarshal(payload, req); err != nil {
|
|
return nil, decodeError("BridgeCallRequest", err)
|
|
}
|
|
lookup, ok := g.services.Bridge.(serviceLookup)
|
|
if !ok {
|
|
return nil, &abiv1.AbiError{
|
|
Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED,
|
|
Message: "bridge does not record services",
|
|
}
|
|
}
|
|
svc, ok := lookup.Service(req.GetServiceName())
|
|
if !ok {
|
|
return nil, &abiv1.AbiError{
|
|
Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED,
|
|
Message: "no bridge service " + req.GetServiceName() + " on this instance (register services in Register, not Load)",
|
|
}
|
|
}
|
|
invokable, ok := svc.(plugin.BridgeInvokable)
|
|
if !ok {
|
|
return nil, &abiv1.AbiError{
|
|
Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED,
|
|
Message: "bridge service " + req.GetServiceName() + " does not implement plugin.BridgeInvokable",
|
|
}
|
|
}
|
|
out, err := invokable.InvokeBridge(ctx, req.GetMethod(), req.GetPayload())
|
|
if err != nil {
|
|
return nil, internalError(err.Error())
|
|
}
|
|
return &abiv1.BridgeCallResponse{Payload: out}, nil
|
|
}
|
|
|
|
// directoryExtensions resolves the registration's DirectoryExtensions value,
|
|
// nil when the plugin declares none.
|
|
func (g *guest) directoryExtensions() *plugin.DirectoryExtensions {
|
|
if g.reg.DirectoryExtensions == nil {
|
|
return nil
|
|
}
|
|
return g.reg.DirectoryExtensions()
|
|
}
|
|
|
|
// directoryPanelSection renders the index-th registered panel section
|
|
// (HOOK_DIRECTORY_PANEL_SECTION).
|
|
func (g *guest) directoryPanelSection(payload []byte) (proto.Message, *abiv1.AbiError) {
|
|
req := &abiv1.DirectoryPanelSectionRequest{}
|
|
if err := proto.Unmarshal(payload, req); err != nil {
|
|
return nil, decodeError("DirectoryPanelSectionRequest", err)
|
|
}
|
|
ext := g.directoryExtensions()
|
|
idx := int(req.GetIndex())
|
|
if ext == nil || idx >= len(ext.PanelSections) || ext.PanelSections[idx] == nil {
|
|
return nil, &abiv1.AbiError{
|
|
Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED,
|
|
Message: fmt.Sprintf("no directory panel section at index %d", idx),
|
|
}
|
|
}
|
|
var data map[string]any
|
|
if raw := req.GetDataJson(); len(raw) > 0 {
|
|
if err := json.Unmarshal(raw, &data); err != nil {
|
|
return nil, decodeError("DirectoryPanelSectionRequest.data_json", err)
|
|
}
|
|
}
|
|
return &abiv1.DirectoryPanelSectionResponse{Html: ext.PanelSections[idx](data)}, nil
|
|
}
|
|
|
|
// directoryPinDecorator runs the index-th registered pin decorator, which
|
|
// mutates the pin map in place; the mutated pin is returned
|
|
// (HOOK_DIRECTORY_PIN_DECORATOR).
|
|
func (g *guest) directoryPinDecorator(payload []byte) (proto.Message, *abiv1.AbiError) {
|
|
req := &abiv1.DirectoryPinDecoratorRequest{}
|
|
if err := proto.Unmarshal(payload, req); err != nil {
|
|
return nil, decodeError("DirectoryPinDecoratorRequest", err)
|
|
}
|
|
ext := g.directoryExtensions()
|
|
idx := int(req.GetIndex())
|
|
if ext == nil || idx >= len(ext.PinDecorators) || ext.PinDecorators[idx] == nil {
|
|
return nil, &abiv1.AbiError{
|
|
Code: abiv1.AbiErrorCode_ABI_ERROR_CODE_UNIMPLEMENTED,
|
|
Message: fmt.Sprintf("no directory pin decorator at index %d", idx),
|
|
}
|
|
}
|
|
pin := map[string]any{}
|
|
if raw := req.GetPinJson(); len(raw) > 0 {
|
|
if err := json.Unmarshal(raw, &pin); err != nil {
|
|
return nil, decodeError("DirectoryPinDecoratorRequest.pin_json", err)
|
|
}
|
|
}
|
|
var data map[string]any
|
|
if raw := req.GetDataJson(); len(raw) > 0 {
|
|
if err := json.Unmarshal(raw, &data); err != nil {
|
|
return nil, decodeError("DirectoryPinDecoratorRequest.data_json", err)
|
|
}
|
|
}
|
|
ext.PinDecorators[idx](pin, data)
|
|
pinJSON, err := json.Marshal(pin)
|
|
if err != nil {
|
|
return nil, internalError("marshal mutated pin: " + err.Error())
|
|
}
|
|
return &abiv1.DirectoryPinDecoratorResponse{PinJson: pinJSON}, nil
|
|
}
|
|
|
|
// sortedKeys returns the map's keys in sorted order (deterministic manifests).
|
|
func sortedKeys[V any](m map[string]V) []string {
|
|
keys := make([]string, 0, len(m))
|
|
for k := range m {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
return keys
|
|
}
|