Files
multica/server/internal/service/plugin_event_dispatch.go
659bb41abd MUL-6350: rebuild the plugin system (3/4) — hook engine (#7182)
* feat(plugins): hook engine — signed outbound calls, trigger-decided identity (MUL-6350)

The direction reverses here. PR 1 and 2 only ever ran inbound: a sandboxed
surface asked the host, and the host acted on the signed-in user's session, so
nothing left our infrastructure on a plugin's say-so. A hook leaves, and every
new check exists because of that.

Destination: the target host must be inside the granted `net:` set, resolve to
a public address, and be re-checked at dial. Body: HMAC-SHA256 over
timestamp.body, so a receiver can tell our call from anyone else's and a
captured one expires.

Identity follows the trigger, not the plugin. A ui/manual hook runs because a
person pressed something, so its writes stay theirs; an event hook has nobody
behind it and writes as the installation — hence the fourth author_type.

Two credentials, moving opposite ways, which is what decides how each is
stored. The install token goes plugin -> host and is only ever verified, so it
is hashed. The signing secret goes host -> plugin and must be reproduced to
sign with, so it is derived from the deployment key and stored nowhere.

Event hooks never block the host: Bus.Publish runs listeners inline on the
publishing request's goroutine, so the dispatcher hands off to a worker pool
immediately. Three attempts, then a circuit breaker, then silence.

Co-authored-by: multica-agent <[email protected]>

* feat(plugins): manual hook actions, ui trigger, and a worked hook example (MUL-6350)

The host owns the manual menu entry rather than letting a surface draw one,
because the trigger decides identity: a manual call acts as the person who
picked it, so the entry has to live where the host can prove somebody did.

The ui trigger goes through the bridge for the same reason a surface cannot
fetch its own backend directly — routing through the host is what makes the
call signed, rate limited, bounded to the granted net: domains and recorded.

buildActorNameResolver gains a plugin branch. Without it an event-written
comment rendered as "System", which is the one attribution that is actively
misleading: it reads as the platform having said something.

examples/plugins/triage-notify carries the half that does NOT run in Multica —
a reference handler showing the four things a receiver must do, including the
replay guard the host cannot do for it.

TestShippedExamplesParseAndInstallOnThisHost closes the gap that let an example
drift: nothing checked that shipped documentation still parses, or that it
declares only what this build can run.

TestCheckCapabilitiesReportsEveryUnavailableContribution rewritten the way the
other two were in PR 2 — it named today's unshipped set, so flipping ui/manual/
event on turned it red with a message about the flip rather than about the gate.
It now derives its expectation from HostCapabilities and survives a flip while
still failing if the gate stops checking a category.

Co-authored-by: multica-agent <[email protected]>

* feat(plugins): modal surface, invocation cleanup, honest capability guards (MUL-6350)

TestShippedHostCapabilitiesRunTheSurfacesTheHostMounts caught me flipping the
modal surface on without a renderer — an install that succeeds and then never
appears, which is the exact failure that guard exists to prevent. Built the
mount point rather than weakening the guard, and rewrote it to name the host
code behind each line so the next flip has to change both together.

plugin_invocation was missing from workspace teardown and from uninstall.
TestWorkspaceDeletionManifestCoversPublicSchema caught the first; the second
would have left rows naming an installation that no longer exists. Deleted by
workspace_id rather than through installation ids, so a record whose plugin was
already uninstalled does not outlive the workspace it describes.

A modal opens because a person picked it from the issue menu, never on the
plugin's own initiative — same sandbox as a panel, differing only in where it
appears.

Co-authored-by: multica-agent <[email protected]>

* test(plugins): prove the event path never blocks, and contain worker panics (MUL-6350)

Writing the never-blocks test found a real bug rather than confirming one.
Bus.Publish recovers panics in its listeners so one bad handler cannot kill the
request that published; moving delivery onto a worker goroutine stepped outside
that protection, where a panic is not a failed hook but a dead process.
runGuarded restores the guarantee the hand-off gave up.

The bridge now takes an EventSink rather than the concrete dispatcher, so the
vocabulary mapping — which internal event becomes which published one — is
asserted directly instead of through a live endpoint. That mapping is a
published contract; the internal names are not.

The backpressure test stops the workers rather than racing them, so it
reproduces a saturated pool without depending on timing, and asserts the exact
overflow instead of "something was dropped".

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): two hook-contract bugs a live run found (MUL-6350)

Both were invisible to the tests because the tests made one call each, and a
real handler does not.

The callback token was single-use. The reference handler reads the issue,
decides, then posts a comment — and the second call died on a spent token.
Read-then-write is the floor for a handler that does anything with what it
read, so a single-use grant made the callback unusable.

The trade that decides it is not one call versus several. It is this token
versus the installation's standing token: a handler that cannot finish with the
callback gets written against the install token instead, which never expires
and is not scoped to an invocation. A control that pushes authors toward the
stronger credential is worse than the looser one they will actually use. The
grant is now invocation-scoped — minutes, this installation's scopes, the actor
fixed at dispatch — and revoked as soon as the call it was issued for returns.

The hook body also omitted the issue. The host resolves and permission-checks
it, then said nothing, so a handler had to read a client-supplied field —
unvalidated for ui/manual, and absent entirely for event, where there is no
client. issue_id now travels in the body.

Also adds MULTICA_PLUGIN_DEV_CA: a hook URL must be HTTPS, so an author testing
against localhost needs its certificate trusted by something. A named CA file
rather than InsecureSkipVerify, which survives into production behind a config
flag nobody remembers and cannot be spotted by reading the code.

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): enforce the callback token's issue scope, sweep invocations (MUL-6350)

Addresses Fabel 5's review. The first item is the one that mattered.

The callback grant's issue narrowing existed only in two doc comments and the
PR description — pluginTokenCaller never read grant.IssueID, so a ui/manual
token was worth every issue in the workspace its actor could see, for the whole
five minutes it lived, on read and write alike. Now carried into the caller and
checked in pluginIssueForUser, after resolution so an identifier and a uuid for
the same issue agree. 404 rather than 403: "you are scoped elsewhere" confirms
the id exists.

DeleteExpiredPluginInvocations was generated and never called, while the table
comment and the PR both said TTL-swept. A sweeper now runs on the dispatcher's
lifecycle — same concern, bounded resources for something a third party drives.

Event subscriptions bypassed the read scopes. issue.* pushes the description
and comment.created pushes the body — the same content the Action API refuses
without issues:read / comments:read. Subscribing was a way to receive what
reading was not granted. Enforced at manifest validation so the consent screen
shows the read scope a subscription implies.

Migration 349's ADD CONSTRAINT copied 107's shape onto a table that is no
longer 107's size: it revalidated every comment row under ACCESS EXCLUSIVE.
NOT VALID plus a 352 that VALIDATEs under SHARE UPDATE EXCLUSIVE costs nothing
and stops nothing.

Plus the half-built breaker UI (a plugin could be enabled and silently
delivering nothing), redirect refusal on the dev hook client (a 302 would have
replayed the signed body and the callback token onward), and omitting
callback_url rather than sending a relative one.

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): the invocation sweeper must not touch the database at construction (MUL-6350)

CI panicked in cmd/server: the sweeper I added last round fired immediately on
its own goroutine, and that test builds a router over a Queries whose pool was
never opened. pgxpool dereferenced the nil pool and the process died.

Two things were wrong, and only one of them is the obvious one.

The sweep no longer runs at construction. It waited for nothing and gained one
hour of retention on a cold start against a 7-day TTL; the cost was a goroutine
reaching for the database before the process is known to have one.

The nil check was also the wrong instrument. sqlc's Queries wraps an executor,
so a non-nil Queries over an unopened pool passes every comparison available at
that layer and only fails inside pgxpool — no amount of nil-checking here can
see it. The guard is a recover, for the same reason runGuarded exists: on a bare
goroutine a panic is not a failed sweep, it is a dead server. I had already
fixed that class once for delivery and then reintroduced it in the sweeper.

The regression test builds the exact shape CI hit — a dispatcher over db.New(nil)
— and fails if either the construction-time sweep or the recover comes back.
cmd/server cannot reproduce it locally, where DATABASE_URL supplies a real pool.

Co-authored-by: multica-agent <[email protected]>

* fix(plugins): gate the event dispatch path behind plugins_v1 (MUL-6350)

Answering "is this still behind the flag" turned up the one place it was not.

Every other plugin surface is refused by a handler that reads the flag off a
request. An event hook has no request: the dispatcher is subscribed to the bus
at startup and delivers on a worker. Nothing in it consulted the flag, so
turning plugins off hid the UI and refused the Action API while the OUTBOUND
calls kept going — fail-open on the one path that reaches a third party.

It only bites after a plugin has been installed, because installation is
flag-gated, so a deployment that never enabled plugins was never exposed. A
workspace that enabled it, installed something, then turned it off was.

The check reads the flag per delivery rather than once at subscription, so the
flip takes effect immediately instead of at the next restart. It also removes
the flag-off cost: every dispatched event used to run a
ListWorkspacePluginInstallations query to discover there was nothing to call.

Nil flags read as disabled — a deployment that never wired the flag service must
not get outbound hooks by omission.

The test lives in internal/handler because it needs a real installed plugin to
mean anything: with the flag on the endpoint is called, with it off nothing
leaves while the same installation stays enabled and still declares the hook.
Removing the check makes it fail on the second half.

Co-authored-by: multica-agent <[email protected]>

* perf(plugins): stop parsing event payloads on the publishing goroutine (MUL-6350)

Found while answering whether merging can affect existing users, and the answer
was "in one measurable way it can".

The bus listener passed issueIDFromPayload(e.Payload) as an argument, so a JSON
marshal plus unmarshal of the full event payload — for issue.created that is the
whole issue body, description included — ran inline on the goroutine of whatever
request published. Seven event types, every workspace, and the feature flag was
checked far downstream on the worker, which then threw the result away. A
deployment with plugins switched off paid for all of it.

The listener now hands the payload over unexamined and the id is extracted on
the worker, past the flag check. It also fits the rule this file already
claimed: the publishing request does the least it possibly can.

Not moved: the status_changed lookup, which is a map read, not a parse.

TestPublishingDoesNotParseThePayload counts MarshalJSON calls during Publish and
fails if any happen — restoring the old argument makes it fail with the count.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Lambda <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-19 19:29:15 +08:00

324 lines
11 KiB
Go

package service
import (
"context"
"log/slog"
"sync"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/featureflags"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/plugincontract"
)
// Event-triggered hooks.
//
// The rule this file exists to keep: an event hook NEVER blocks the host. The
// event bus is synchronous — Bus.Publish runs its listeners inline, on the
// goroutine of whatever request produced the event — so a listener that dialled
// a third-party endpoint would put an outside server on the critical path of
// creating an issue. Everything here therefore hands off to a bounded worker
// pool and returns immediately.
//
// The same reasoning is why the agent execution path has no hook at all: a hook
// that must run before or after every agent turn is a third party holding the
// product's main loop open. Agents reach hooks in PR 4 by choosing to call one
// as a tool, which is a call they can decline.
const (
// Queue depth. Full means events are arriving faster than endpoints can
// answer; the overflow is dropped and counted rather than queued forever,
// because an unbounded queue turns a slow plugin into a memory leak.
dispatchQueueDepth = 512
dispatchWorkers = 4
// How long a hook call stays on record. This table is operational telemetry,
// not history: it answers "why is this endpoint failing right now", and the
// circuit breaker and rate limiter only ever look minutes back. Keeping it
// forever would grow an append-only table at the rate of the per-hook limit
// — 120/minute/hook — for no reader.
invocationRetention = 7 * 24 * time.Hour
invocationSweepEvery = time.Hour
)
// PluginEventDispatcher fans domain events out to the hooks that asked for them.
type PluginEventDispatcher struct {
service *PluginService
queue chan dispatchJob
wg sync.WaitGroup
stop chan struct{}
once sync.Once
// dropped counts events shed under backpressure, surfaced for triage.
mu sync.Mutex
dropped int
}
type dispatchJob struct {
installation db.PluginInstallation
hook plugincontract.Hook
eventType string
issueID pgtype.UUID
payload any
}
func NewPluginEventDispatcher(service *PluginService) *PluginEventDispatcher {
dispatcher := &PluginEventDispatcher{
service: service,
queue: make(chan dispatchJob, dispatchQueueDepth),
stop: make(chan struct{}),
}
for i := 0; i < dispatchWorkers; i++ {
dispatcher.wg.Add(1)
go dispatcher.work()
}
dispatcher.wg.Add(1)
go dispatcher.sweepInvocations()
return dispatcher
}
// sweepInvocations makes the table's "TTL-swept" description true.
//
// Nothing reads a row older than the breaker and rate-limit windows, both of
// which look minutes back, so without this the table only grows — at up to the
// per-hook limit of 120 rows a minute, per hook, forever.
//
// Runs on the dispatcher's own lifecycle because it is the same concern:
// bounded resources for something a third party's behaviour drives.
//
// The first sweep waits for the first tick rather than firing at construction.
// An earlier version swept immediately, on the theory that a deployment down for
// a week should not wait an hour — and it panicked in cmd/server's router test,
// where the dispatcher is built over a Queries whose pool was never opened. The
// benefit was one hour of retention on a cold start against a 7-day TTL; the
// cost was a goroutine touching the database before the process is known to have
// one.
func (d *PluginEventDispatcher) sweepInvocations() {
defer d.wg.Done()
ticker := time.NewTicker(invocationSweepEvery)
defer ticker.Stop()
for {
select {
case <-d.stop:
return
case <-ticker.C:
d.sweepOnce()
}
}
}
// sweepOnce deletes what has aged out.
//
// Panics are contained for the same reason runGuarded exists: this runs on a
// bare goroutine, where an unrecovered panic is not a failed sweep but a dead
// process. A nil check on Queries is NOT enough to prevent one — sqlc's Queries
// wraps an executor, so a non-nil Queries over an unopened pool passes every
// check available here and then dereferences inside pgxpool. That is exactly how
// this took down cmd/server's test, and it is why the guard is a recover rather
// than one more nil comparison.
func (d *PluginEventDispatcher) sweepOnce() {
defer func() {
if recovered := recover(); recovered != nil {
slog.Error("plugins: panic while sweeping hook invocations", "recovered", recovered)
}
}()
if d.service == nil || d.service.Queries == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
cutoff := pgtype.Timestamptz{Time: time.Now().Add(-invocationRetention), Valid: true}
removed, err := d.service.Queries.DeleteExpiredPluginInvocations(ctx, cutoff)
if err != nil {
slog.Warn("plugins: invocation sweep failed", "error", err)
return
}
if removed > 0 {
slog.Info("plugins: swept expired hook invocations", "removed", removed)
}
}
// Dispatch is what the bus listener calls. It must return promptly: the caller
// is a live request that has already done its real work.
//
// Note it takes no context from that request. Tying an outbound hook to the
// request that triggered it would cancel the hook the moment the browser got
// its response, which is exactly when the hook is only just starting.
func (d *PluginEventDispatcher) Dispatch(eventType, workspaceID string, payload any) {
if d == nil || d.service == nil || workspaceID == "" {
return
}
parsedWorkspace, err := parseUUIDValue(workspaceID)
if err != nil {
return
}
// Nothing is inspected here. The installation lookup is a database read and
// finding the issue id is a JSON round-trip; both belong on a worker, and
// both sit behind the feature-flag check so a deployment with plugins off
// pays for neither.
select {
case d.queue <- dispatchJob{
installation: db.PluginInstallation{WorkspaceID: parsedWorkspace},
eventType: eventType,
payload: payload,
}:
default:
d.mu.Lock()
d.dropped++
dropped := d.dropped
d.mu.Unlock()
slog.Warn("plugins: event dispatch queue full, dropping event", "event_type", eventType, "dropped_total", dropped)
}
}
// Dropped reports how many events were shed under backpressure.
func (d *PluginEventDispatcher) Dropped() int {
d.mu.Lock()
defer d.mu.Unlock()
return d.dropped
}
func (d *PluginEventDispatcher) work() {
defer d.wg.Done()
for {
select {
case <-d.stop:
return
case job := <-d.queue:
d.runGuarded(job)
}
}
}
// runGuarded contains a panic to the delivery that caused it.
//
// Bus.Publish recovers panics in its listeners so one bad handler cannot take
// down the request that published. Moving delivery onto a worker goroutine
// steps outside that protection, and a panic on a bare goroutine is not a
// failed hook but a dead process. Restoring the guarantee is the cost of the
// hand-off, not an optional extra.
func (d *PluginEventDispatcher) runGuarded(job dispatchJob) {
defer func() {
if recovered := recover(); recovered != nil {
slog.Error("plugins: panic while delivering an event hook",
"event_type", job.eventType, "recovered", recovered)
}
}()
d.run(job)
}
// run resolves which hooks want this event and calls each one.
func (d *PluginEventDispatcher) run(job dispatchJob) {
if d.service.Queries == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
// The flag gates this path too, and checking it HERE rather than at
// subscription is the point: a deployment that turns plugins off after
// something was installed must stop the outbound calls, not just hide the
// UI. Reading it per delivery means the flip takes effect immediately
// instead of at the next restart.
//
// It also keeps the flag-off cost at zero. Without this every dispatched
// event ran a ListWorkspacePluginInstallations query to discover there was
// nothing to call.
if !featureflags.PluginsV1Enabled(ctx, d.service.FeatureFlags) {
return
}
// Only now, past the flag: the id is needed to narrow the callback grant,
// and finding it means parsing the payload.
job.issueID = issueIDFromPayload(job.payload)
installations, err := d.service.Queries.ListWorkspacePluginInstallations(ctx, job.installation.WorkspaceID)
if err != nil {
slog.Warn("plugins: event dispatch could not list installations", "error", err)
return
}
for _, installation := range installations {
if !installation.Enabled {
continue
}
manifest, err := ParseInstallationManifest(installation)
if err != nil {
continue
}
for _, hook := range manifest.Contributes.Hooks {
if !HookAllowsTrigger(hook, plugincontract.TriggerEvent) || !hookWantsEvent(hook, job.eventType) {
continue
}
d.deliver(ctx, installation, hook, job)
}
}
}
func hookWantsEvent(hook plugincontract.Hook, eventType string) bool {
for _, declared := range hook.Events {
if declared == eventType {
return true
}
}
return false
}
// deliver runs one hook with the event retry schedule.
func (d *PluginEventDispatcher) deliver(ctx context.Context, installation db.PluginInstallation, hook plugincontract.Hook, job dispatchJob) {
// A hook whose endpoint has been failing is not retried on every event.
// Without this, an endpoint that has been down for an hour receives one
// doomed request per workspace event, forever.
if d.service.HookBreakerOpen(ctx, installation.ID, hook.Key) {
slog.Info("plugins: hook circuit open, skipping event", "hook", hook.Key, "event_type", job.eventType)
return
}
invocation := HookInvocation{
Installation: installation,
Hook: hook,
Trigger: plugincontract.TriggerEvent,
EventType: job.eventType,
// An event has no person behind it. Writes it produces are the
// plugin's own, attributed to the installation.
Actor: HookActor{Type: "plugin", ID: installation.ID},
IssueID: job.issueID,
Input: job.payload,
}
for attempt := 1; attempt <= hookEventAttempts; attempt++ {
_, err := d.service.InvokeHook(ctx, invocation, attempt)
if err == nil {
return
}
// A refusal is a decision, not an outage: retrying a hook that is
// disabled, out of scope or rate limited just burns the budget.
if hookFailureStatus(err) == "refused" {
slog.Info("plugins: event hook refused", "hook", hook.Key, "error", redactHookError(err))
return
}
if attempt == hookEventAttempts {
slog.Warn("plugins: event hook failed after retries", "hook", hook.Key, "event_type", job.eventType, "error", redactHookError(err))
return
}
select {
case <-ctx.Done():
return
case <-time.After(time.Duration(attempt) * hookEventBackoff):
}
}
}
// Close stops the workers. Safe to call more than once.
func (d *PluginEventDispatcher) Close() {
if d == nil {
return
}
d.once.Do(func() {
close(d.stop)
d.wg.Wait()
})
}