Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions automation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package plugin

import "encoding/json"

func marshalConditionResult(r ConditionResult) []byte {
data, _ := json.Marshal(r)
return data
}

func marshalActionResult(r ActionResult) []byte {
data, _ := json.Marshal(r)
return data
}

// TaskSnapshot is the read-only task context the host includes alongside a
// plugin-contributed automation node's config when it dispatches an
// EvaluateCondition or RunAction call. It mirrors the subset of task fields
// the host's pluginNodePayload sends — see
// services/api/internal/worker/automation_consumer.go's pluginNodePayload.
type TaskSnapshot struct {
ID string `json:"id"`
StatusID *string `json:"status_id"`
AssigneeIDs []string `json:"assignee_ids"`
Importance int `json:"importance"`
Tags []string `json:"tags"`
CustomFields map[string]any `json:"custom_fields"`
}

// ConditionRequest is the payload a plugin's Condition handler receives:
// the automation node's own type (matching the Type declared in the
// plugin's manifest under automation.conditions — useful when a plugin
// registers more than one Condition handler and needs to disambiguate, but
// [Context.Condition] already dispatches by type so most handlers can
// ignore this field), config (opaque to the host, validated only by the
// plugin), a snapshot of the task being evaluated, and the project the
// automation run belongs to. ProjectID is supplied by the host itself (the
// automation graph's own project) rather than read from Config — a plugin
// condition should never need to ask a user to type a project ID into the
// node's config just to know which project it's scoped to.
type ConditionRequest struct {
NodeType string `json:"node_type"`
Config json.RawMessage `json:"config"`
Task TaskSnapshot `json:"task"`
ProjectID string `json:"project_id"`
}

// ConditionResult is the response a Condition handler returns. Matched
// selects which outgoing edge the automation graph walk follows next: the
// node's "true" handle when true, its "else" handle otherwise. Error, when
// non-empty, signals the condition could not be evaluated at all — a bad
// host payload, an unregistered node type, or a plugin init failure — as
// distinct from a handler legitimately evaluating to false. Mirrors
// [ActionResult.Error].
type ConditionResult struct {
Matched bool `json:"matched"`
Error string `json:"error,omitempty"`
}

// ActionRequest is the payload a plugin's Action handler receives: the
// automation node's own type (see [ConditionRequest.NodeType] for why this
// is included), config, a snapshot of the task, the project the automation
// run belongs to (see [ConditionRequest.ProjectID] — same host-supplied
// field, same reasoning), plus a stable (run, node) idempotency key. The
// platform cannot enforce idempotency inside a plugin's own WASM code, so
// IdempotencyKey is provided as something stable to dedupe against in the
// plugin's own schema-isolated tables, if it chooses to.
type ActionRequest struct {
NodeType string `json:"node_type"`
Config json.RawMessage `json:"config"`
Task TaskSnapshot `json:"task"`
ProjectID string `json:"project_id"`
IdempotencyKey string `json:"idempotency_key"`
}

// ActionResult is the response a plugin's Action handler returns. Applied
// reports whether the action actually changed anything (mirrors the
// idempotency-check pattern built-in actions use — e.g. "already set to
// this value" returns Applied: false with no Error). Error, when non-empty,
// stops the automation graph walk down this branch.
type ActionResult struct {
Applied bool `json:"applied"`
Error string `json:"error,omitempty"`
}

// ConditionHandler evaluates a plugin-contributed Condition automation
// node. Register one via [Context.Condition].
type ConditionHandler func(req *ConditionRequest) ConditionResult

// ActionHandler executes a plugin-contributed Action automation node.
// Register one via [Context.Action].
type ActionHandler func(req *ActionRequest) ActionResult

// Condition registers a handler for a plugin-contributed automation
// Condition node type. nodeType must match the Type declared in the
// plugin's manifest under automation.conditions, namespaced under the
// plugin's short name — the last dot-separated segment of the plugin ID,
// snake_cased (e.g. plugin ID "com.paca.github" -> prefix "github.", so
// "github.pr_state"; "com.paca.time-logging" -> prefix "time_logging.").
//
// Only one handler may be registered per nodeType; registering the same
// nodeType twice replaces the previous handler.
func (c *Context) Condition(nodeType string, handler ConditionHandler) {
c.conditions[nodeType] = handler
}

// Action registers a handler for a plugin-contributed automation Action
// node type. nodeType must match the Type declared in the plugin's
// manifest under automation.actions, namespaced under the plugin's short
// name — the last dot-separated segment of the plugin ID, snake_cased
// (e.g. plugin ID "com.paca.github" -> prefix "github.", so
// "github.merge_pr"; "com.paca.time-logging" -> prefix "time_logging.").
//
// Only one handler may be registered per nodeType; registering the same
// nodeType twice replaces the previous handler.
func (c *Context) Action(nodeType string, handler ActionHandler) {
c.actions[nodeType] = handler
}
43 changes: 24 additions & 19 deletions context.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,21 @@ package plugin

import "strings"

// Context is passed to [Plugin.Init] and used to register route handlers and
// event subscriptions. It also gives access to platform services such as the
// database, key-value store, logger, and configuration.
// Context is passed to [Plugin.Init] and used to register route handlers,
// event subscriptions, and automation-graph node handlers. It also gives
// access to platform services such as the database, key-value store,
// logger, and configuration.
type Context struct {
routes map[routeKey]RouteHandler
events map[string]EventHandler
db *DB
kv *KV
cache *Cache
log *Logger
cfg *Config
perm *Permissions
routes map[routeKey]RouteHandler
events map[string]EventHandler
conditions map[string]ConditionHandler
actions map[string]ActionHandler
db *DB
kv *KV
cache *Cache
log *Logger
cfg *Config
perm *Permissions
}

// routeKey uniquely identifies a registered route by HTTP method + path.
Expand Down Expand Up @@ -73,14 +76,16 @@ type EventHandler func(evt *Event)
// [plugintest] (with in-memory backends).
func newContext(db DBBackend, kv KVBackend, cache CacheBackend, log LogBackend, cfg ConfigBackend, perm PermissionBackend) *Context {
return &Context{
routes: make(map[routeKey]RouteHandler),
events: make(map[string]EventHandler),
db: &DB{backend: db},
kv: &KV{backend: kv},
cache: &Cache{backend: cache},
log: &Logger{backend: log},
cfg: &Config{backend: cfg},
perm: &Permissions{backend: perm},
routes: make(map[routeKey]RouteHandler),
events: make(map[string]EventHandler),
conditions: make(map[string]ConditionHandler),
actions: make(map[string]ActionHandler),
db: &DB{backend: db},
kv: &KV{backend: kv},
cache: &Cache{backend: cache},
log: &Logger{backend: log},
cfg: &Config{backend: cfg},
perm: &Permissions{backend: perm},
}
}

Expand Down
52 changes: 52 additions & 0 deletions dispatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,55 @@ func (d *dispatcher) handleEvent(topic string, payload []byte) {
}
handler(&Event{Topic: topic, Payload: payload})
}

// evaluateCondition deserialises the host's condition-evaluation payload
// (node type + config + task snapshot) and dispatches it to the
// plugin-contributed Condition handler registered for that node type via
// [Context.Condition]. Mirrors handleRequest's ordering: the payload is
// parsed before the plugin is initialised, so a malformed payload fails
// fast without paying for Init. Every failure path (bad payload, init
// failure, unregistered node type) sets [ConditionResult.Error] so it can't
// be mistaken for a handler legitimately evaluating to false.
//
//nolint:unused // used by wasm_exports.go in WASM builds
func (d *dispatcher) evaluateCondition(payload []byte) []byte {
var req ConditionRequest
if err := unmarshalJSON(payload, &req); err != nil {
return marshalConditionResult(ConditionResult{Matched: false, Error: "bad request payload: " + err.Error()})
}

if err := d.init(); err != nil {
return marshalConditionResult(ConditionResult{Matched: false, Error: "plugin init failed: " + err.Error()})
}

handler, ok := d.ctx.conditions[req.NodeType]
if !ok {
return marshalConditionResult(ConditionResult{Matched: false, Error: "no condition handler registered for node type " + req.NodeType})
}
return marshalConditionResult(handler(&req))
}

// runAction deserialises the host's action-execution payload (node type +
// config + task snapshot + idempotency key) and dispatches it to the
// plugin-contributed Action handler registered for that node type via
// [Context.Action]. Mirrors handleRequest's ordering: the payload is parsed
// before the plugin is initialised, so a malformed payload fails fast
// without paying for Init.
//
//nolint:unused // used by wasm_exports.go in WASM builds
func (d *dispatcher) runAction(payload []byte) []byte {
var req ActionRequest
if err := unmarshalJSON(payload, &req); err != nil {
return marshalActionResult(ActionResult{Applied: false, Error: "bad request payload: " + err.Error()})
}

if err := d.init(); err != nil {
return marshalActionResult(ActionResult{Applied: false, Error: "plugin init failed: " + err.Error()})
}

handler, ok := d.ctx.actions[req.NodeType]
if !ok {
return marshalActionResult(ActionResult{Applied: false, Error: "no action handler registered for node type " + req.NodeType})
}
return marshalActionResult(handler(&req))
}
160 changes: 160 additions & 0 deletions dispatch_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package plugin

import (
"encoding/json"
"errors"
"strings"
"testing"
)

// fakeAutomationPlugin is a minimal Plugin used to exercise the
// dispatcher's evaluateCondition/runAction paths without a WASM host.
type fakeAutomationPlugin struct {
initErr error
initCalled int
condition ConditionHandler
action ActionHandler
}

func (p *fakeAutomationPlugin) Init(ctx *Context) error {
p.initCalled++
if p.initErr != nil {
return p.initErr
}
if p.condition != nil {
ctx.Condition("test.cond", p.condition)
}
if p.action != nil {
ctx.Action("test.action", p.action)
}
return nil
}

func (p *fakeAutomationPlugin) Shutdown() {}

func decodeConditionResult(t *testing.T, data []byte) ConditionResult {
t.Helper()
var got ConditionResult
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal ConditionResult: %v", err)
}
return got
}

func decodeActionResult(t *testing.T, data []byte) ActionResult {
t.Helper()
var got ActionResult
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal ActionResult: %v", err)
}
return got
}

func TestDispatcherEvaluateCondition(t *testing.T) {
t.Run("dispatches to the registered handler", func(t *testing.T) {
p := &fakeAutomationPlugin{
condition: func(req *ConditionRequest) ConditionResult {
return ConditionResult{Matched: req.Task.Importance > 5}
},
}
d := newDispatcher(p)
payload, _ := json.Marshal(ConditionRequest{NodeType: "test.cond", Task: TaskSnapshot{Importance: 10}})

got := decodeConditionResult(t, d.evaluateCondition(payload))
if !got.Matched || got.Error != "" {
t.Fatalf("got %+v, want Matched=true Error=\"\"", got)
}
})

t.Run("bad payload fails fast without initialising the plugin", func(t *testing.T) {
p := &fakeAutomationPlugin{}
d := newDispatcher(p)

got := decodeConditionResult(t, d.evaluateCondition([]byte("not json")))
if got.Matched {
t.Fatalf("got Matched=true for a bad payload, want false")
}
if !strings.Contains(got.Error, "bad request payload") {
t.Fatalf("got Error %q, want it to mention the bad payload", got.Error)
}
if p.initCalled != 0 {
t.Fatalf("plugin Init called %d times for a payload that never parsed, want 0", p.initCalled)
}
})

t.Run("plugin init failure is reported distinctly from a false match", func(t *testing.T) {
p := &fakeAutomationPlugin{initErr: errors.New("boom")}
d := newDispatcher(p)
payload, _ := json.Marshal(ConditionRequest{NodeType: "test.cond"})

got := decodeConditionResult(t, d.evaluateCondition(payload))
if got.Matched || !strings.Contains(got.Error, "plugin init failed") {
t.Fatalf("got %+v, want Matched=false and an init-failure Error", got)
}
})

t.Run("unregistered node type is reported distinctly from a real false match", func(t *testing.T) {
p := &fakeAutomationPlugin{
condition: func(req *ConditionRequest) ConditionResult { return ConditionResult{Matched: false} },
}
d := newDispatcher(p)
payload, _ := json.Marshal(ConditionRequest{NodeType: "unknown.type"})

got := decodeConditionResult(t, d.evaluateCondition(payload))
if got.Matched || !strings.Contains(got.Error, "no condition handler registered") {
t.Fatalf("got %+v, want an unregistered-handler Error", got)
}
})
}

func TestDispatcherRunAction(t *testing.T) {
t.Run("dispatches to the registered handler", func(t *testing.T) {
p := &fakeAutomationPlugin{
action: func(req *ActionRequest) ActionResult {
return ActionResult{Applied: req.IdempotencyKey != ""}
},
}
d := newDispatcher(p)
payload, _ := json.Marshal(ActionRequest{NodeType: "test.action", IdempotencyKey: "run-1/node-2"})

got := decodeActionResult(t, d.runAction(payload))
if !got.Applied || got.Error != "" {
t.Fatalf("got %+v, want Applied=true Error=\"\"", got)
}
})

t.Run("bad payload fails fast without initialising the plugin", func(t *testing.T) {
p := &fakeAutomationPlugin{}
d := newDispatcher(p)

got := decodeActionResult(t, d.runAction([]byte("not json")))
if got.Applied || !strings.Contains(got.Error, "bad request payload") {
t.Fatalf("got %+v, want a bad-payload Error", got)
}
if p.initCalled != 0 {
t.Fatalf("plugin Init called %d times for a payload that never parsed, want 0", p.initCalled)
}
})

t.Run("plugin init failure is reported", func(t *testing.T) {
p := &fakeAutomationPlugin{initErr: errors.New("boom")}
d := newDispatcher(p)
payload, _ := json.Marshal(ActionRequest{NodeType: "test.action"})

got := decodeActionResult(t, d.runAction(payload))
if got.Applied || !strings.Contains(got.Error, "plugin init failed") {
t.Fatalf("got %+v, want an init-failure Error", got)
}
})

t.Run("unregistered node type is reported", func(t *testing.T) {
p := &fakeAutomationPlugin{}
d := newDispatcher(p)
payload, _ := json.Marshal(ActionRequest{NodeType: "unknown.type"})

got := decodeActionResult(t, d.runAction(payload))
if got.Applied || !strings.Contains(got.Error, "no action handler registered") {
t.Fatalf("got %+v, want an unregistered-handler Error", got)
}
})
}
Loading
Loading