From f2e1c58a1bd85caec0f4bc01b2e0cd91792bd352 Mon Sep 17 00:00:00 2001 From: pikann Date: Thu, 30 Jul 2026 11:58:12 +0000 Subject: [PATCH 1/3] feat: add Condition/Action automation node registration + dispatch Adds SDK support for plugins to register Condition and Action automation-graph node handlers, alongside the existing event handlers. - automation.go: ConditionRequest/ConditionResult, ActionRequest/ ActionResult (with NodeType + TaskSnapshot), and the marshal helpers used by both the WASM and native dispatch paths. - context.go: Context.Condition(nodeType, handler) and Context.Action(nodeType, handler) registration API, mirroring the existing Context.Event(topic, handler) pattern. - dispatch.go: dispatcher.evaluateCondition/runAction deserialize the host payload (now including node_type - see the paired paca core change) and route to the handler registered for that node type. - wasm_exports.go: EvaluateCondition/RunAction wasmexport functions, using the same (ptr,len)->int64 packed-pointer calling convention as the existing HandleRequest export (confirmed against the host runtime's callExport in paca core's platform/plugin/runtime.go). - testing.go + plugintest/plugintest.go: DispatchCondition/ DispatchAction native-test helpers, and Context.EvaluateCondition/ Context.RunAction test-harness methods for plugin unit tests. Verified: go build/vet/test clean (native), GOOS=wasip1 GOARCH=wasm go build clean. --- automation.go | 107 +++++++++++++++++++++++++++++++++++++++ context.go | 43 +++++++++------- dispatch.go | 46 +++++++++++++++++ plugintest/plugintest.go | 76 +++++++++++++++++++++++++++ testing.go | 30 +++++++++++ wasm_exports.go | 41 +++++++++++++++ 6 files changed, 324 insertions(+), 19 deletions(-) create mode 100644 automation.go diff --git a/automation.go b/automation.go new file mode 100644 index 0000000..6632d27 --- /dev/null +++ b/automation.go @@ -0,0 +1,107 @@ +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) and config (opaque to the host, validated only by the +// plugin) plus a snapshot of the task being evaluated. +type ConditionRequest struct { + NodeType string `json:"node_type"` + Config json.RawMessage `json:"config"` + Task TaskSnapshot `json:"task"` +} + +// 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. +type ConditionResult struct { + Matched bool `json:"matched"` +} + +// 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, plus a snapshot of the task, 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"` + 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 (reverse-DNS namespaced +// under the plugin's own ID, e.g. "com.paca.github.pr_state"). +// +// 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) { + if c.conditions == nil { + c.conditions = make(map[string]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 (reverse-DNS namespaced under the +// plugin's own ID, e.g. "com.paca.github.merge_pr"). +// +// 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) { + if c.actions == nil { + c.actions = make(map[string]ActionHandler) + } + c.actions[nodeType] = handler +} diff --git a/context.go b/context.go index 3d594e2..61e1003 100644 --- a/context.go +++ b/context.go @@ -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. @@ -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}, } } diff --git a/dispatch.go b/dispatch.go index 589fa3a..ad645e9 100644 --- a/dispatch.go +++ b/dispatch.go @@ -115,3 +115,49 @@ 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]. +// +//nolint:unused // used by wasm_exports.go in WASM builds +func (d *dispatcher) evaluateCondition(payload []byte) []byte { + if err := d.init(); err != nil { + return marshalConditionResult(ConditionResult{Matched: false}) + } + + var req ConditionRequest + if err := unmarshalJSON(payload, &req); err != nil { + return marshalConditionResult(ConditionResult{Matched: false}) + } + + handler, ok := d.ctx.conditions[req.NodeType] + if !ok { + return marshalConditionResult(ConditionResult{Matched: false}) + } + 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]. +// +//nolint:unused // used by wasm_exports.go in WASM builds +func (d *dispatcher) runAction(payload []byte) []byte { + if err := d.init(); err != nil { + return marshalActionResult(ActionResult{Applied: false, Error: "plugin init failed: " + err.Error()}) + } + + var req ActionRequest + if err := unmarshalJSON(payload, &req); err != nil { + return marshalActionResult(ActionResult{Applied: false, Error: "bad request payload: " + 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)) +} diff --git a/plugintest/plugintest.go b/plugintest/plugintest.go index cafc0f3..8dfeab2 100644 --- a/plugintest/plugintest.go +++ b/plugintest/plugintest.go @@ -95,6 +95,42 @@ func (c *Context) Call(method, path string, req Request) *plugin.Response { return c.dispatcher.call(method, path, req) } +// EvaluateCondition dispatches to the Condition handler registered for +// nodeType (via [plugin.Context.Condition] in the plugin's Init) and +// returns its result. It fails the test via t.Fatalf if no handler is +// registered for nodeType. +func (c *Context) EvaluateCondition(nodeType string, req ConditionRequest) plugin.ConditionResult { + c.t.Helper() + pluginReq := &plugin.ConditionRequest{ + NodeType: nodeType, + Config: req.Config, + Task: req.Task, + } + result, ok := plugin.DispatchCondition(c.pluginCtx, pluginReq) + if !ok { + c.t.Fatalf("plugintest: no condition handler registered for node type %q", nodeType) + } + return result +} + +// RunAction dispatches to the Action handler registered for nodeType (via +// [plugin.Context.Action] in the plugin's Init) and returns its result. It +// fails the test via t.Fatalf if no handler is registered for nodeType. +func (c *Context) RunAction(nodeType string, req ActionRequest) plugin.ActionResult { + c.t.Helper() + pluginReq := &plugin.ActionRequest{ + NodeType: nodeType, + Config: req.Config, + Task: req.Task, + IdempotencyKey: req.IdempotencyKey, + } + result, ok := plugin.DispatchAction(c.pluginCtx, pluginReq) + if !ok { + c.t.Fatalf("plugintest: no action handler registered for node type %q", nodeType) + } + return result +} + // ── Request ─────────────────────────────────────────────────────────────────── // Request represents a test HTTP request. @@ -122,6 +158,46 @@ func (r Request) WithJSONBody(v any) Request { return r } +// ── Condition / Action ─────────────────────────────────────────────────────── + +// ConditionRequest represents a test automation Condition evaluation. Config +// and Task are forwarded verbatim to the registered handler; the node type +// is passed separately to [Context.EvaluateCondition]. +type ConditionRequest struct { + // Config is the node's raw JSON config, as it would come from the + // automation graph. + Config json.RawMessage + // Task is the task snapshot the handler receives alongside Config. + Task plugin.TaskSnapshot +} + +// WithJSONConfig sets Config to the JSON-encoded form of v. +func (r ConditionRequest) WithJSONConfig(v any) ConditionRequest { + data, _ := json.Marshal(v) + r.Config = data + return r +} + +// ActionRequest represents a test automation Action execution. Config and +// Task are forwarded verbatim to the registered handler; the node type is +// passed separately to [Context.RunAction]. +type ActionRequest struct { + // Config is the node's raw JSON config, as it would come from the + // automation graph. + Config json.RawMessage + // Task is the task snapshot the handler receives alongside Config. + Task plugin.TaskSnapshot + // IdempotencyKey is the stable (run, node) key handed to the handler. + IdempotencyKey string +} + +// WithJSONConfig sets Config to the JSON-encoded form of v. +func (r ActionRequest) WithJSONConfig(v any) ActionRequest { + data, _ := json.Marshal(v) + r.Config = data + return r +} + // ── testDispatcher ──────────────────────────────────────────────────────────── type testDispatcher struct { diff --git a/testing.go b/testing.go index 5a0b1d1..a68a1b3 100644 --- a/testing.go +++ b/testing.go @@ -45,3 +45,33 @@ func DispatchEvent(ctx *Context, topic string, payload []byte) bool { handler(&Event{Topic: topic, Payload: payload}) return true } + +// DispatchCondition calls the Condition handler registered for nodeType in +// ctx (via [Context.Condition]) and returns its result. Returns false (with +// a zero-value ConditionResult) when no handler is registered for that +// node type. +// +// Intended for use in plugin unit tests; production dispatch goes through +// the WASM EvaluateCondition export. +func DispatchCondition(ctx *Context, req *ConditionRequest) (ConditionResult, bool) { + handler, ok := ctx.conditions[req.NodeType] + if !ok { + return ConditionResult{}, false + } + return handler(req), true +} + +// DispatchAction calls the Action handler registered for nodeType in ctx +// (via [Context.Action]) and returns its result. Returns false (with a +// zero-value ActionResult) when no handler is registered for that node +// type. +// +// Intended for use in plugin unit tests; production dispatch goes through +// the WASM RunAction export. +func DispatchAction(ctx *Context, req *ActionRequest) (ActionResult, bool) { + handler, ok := ctx.actions[req.NodeType] + if !ok { + return ActionResult{}, false + } + return handler(req), true +} diff --git a/wasm_exports.go b/wasm_exports.go index a8e6715..72b2456 100644 --- a/wasm_exports.go +++ b/wasm_exports.go @@ -66,6 +66,47 @@ func ResetAllocator() { wasmResetAllocator() } +//go:wasmexport EvaluateCondition +func EvaluateCondition(ptr, length int32) int64 { + if globalDispatcher == nil { + return 0 + } + payload := wasmSlice(ptr, length) + result := globalDispatcher.evaluateCondition(payload) + return packWASMResult(result) +} + +//go:wasmexport RunAction +func RunAction(ptr, length int32) int64 { + if globalDispatcher == nil { + return 0 + } + payload := wasmSlice(ptr, length) + result := globalDispatcher.runAction(payload) + return packWASMResult(result) +} + +// packWASMResult allocates space in mallocBuffer for result, copies it in, +// and returns the packed (ptr<<32)|len combined offset+length the host +// expects — the same packing HandleRequest, EvaluateCondition, and RunAction +// all use. NOTE: Host MUST copy out the response before calling +// ResetAllocator, which is called after each export call completes. +func packWASMResult(result []byte) int64 { + if len(result) == 0 { + return 0 + } + outPtr := wasmMalloc(int32(len(result))) + if outPtr == 0 { + return 0 + } + out := wasmSlice(outPtr, int32(len(result))) + if len(out) != len(result) { + return 0 + } + copy(out, result) + return (int64(outPtr) << 32) | int64(len(result)) +} + //go:wasmexport HandleEvent func HandleEvent(topicPtr, topicLen, payloadPtr, payloadLen int32) { if globalDispatcher == nil { From 998417e6b29b730833ae112eeff542464f807512 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Mon, 3 Aug 2026 10:23:44 +0000 Subject: [PATCH 2/3] feat: add ProjectID to ConditionRequest and ActionRequest for automation context --- automation.go | 39 +++++++++++++++++++++++++-------------- plugintest/plugintest.go | 14 +++++++++++--- 2 files changed, 36 insertions(+), 17 deletions(-) diff --git a/automation.go b/automation.go index 6632d27..40d1b62 100644 --- a/automation.go +++ b/automation.go @@ -31,12 +31,17 @@ type TaskSnapshot struct { // 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) and config (opaque to the host, validated only by the -// plugin) plus a snapshot of the task being evaluated. +// 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"` + 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 @@ -48,15 +53,17 @@ type ConditionResult struct { // 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, plus a snapshot of the task, 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. +// 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"` } @@ -80,8 +87,10 @@ 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 (reverse-DNS namespaced -// under the plugin's own ID, e.g. "com.paca.github.pr_state"). +// 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. @@ -94,8 +103,10 @@ func (c *Context) Condition(nodeType string, handler ConditionHandler) { // 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 (reverse-DNS namespaced under the -// plugin's own ID, e.g. "com.paca.github.merge_pr"). +// 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. diff --git a/plugintest/plugintest.go b/plugintest/plugintest.go index 8dfeab2..2b5e28c 100644 --- a/plugintest/plugintest.go +++ b/plugintest/plugintest.go @@ -102,9 +102,10 @@ func (c *Context) Call(method, path string, req Request) *plugin.Response { func (c *Context) EvaluateCondition(nodeType string, req ConditionRequest) plugin.ConditionResult { c.t.Helper() pluginReq := &plugin.ConditionRequest{ - NodeType: nodeType, - Config: req.Config, - Task: req.Task, + NodeType: nodeType, + Config: req.Config, + Task: req.Task, + ProjectID: req.ProjectID, } result, ok := plugin.DispatchCondition(c.pluginCtx, pluginReq) if !ok { @@ -122,6 +123,7 @@ func (c *Context) RunAction(nodeType string, req ActionRequest) plugin.ActionRes NodeType: nodeType, Config: req.Config, Task: req.Task, + ProjectID: req.ProjectID, IdempotencyKey: req.IdempotencyKey, } result, ok := plugin.DispatchAction(c.pluginCtx, pluginReq) @@ -169,6 +171,9 @@ type ConditionRequest struct { Config json.RawMessage // Task is the task snapshot the handler receives alongside Config. Task plugin.TaskSnapshot + // ProjectID is the project the automation run belongs to, as the host + // would supply it (see plugin.ConditionRequest.ProjectID). + ProjectID string } // WithJSONConfig sets Config to the JSON-encoded form of v. @@ -187,6 +192,9 @@ type ActionRequest struct { Config json.RawMessage // Task is the task snapshot the handler receives alongside Config. Task plugin.TaskSnapshot + // ProjectID is the project the automation run belongs to, as the host + // would supply it (see plugin.ActionRequest.ProjectID). + ProjectID string // IdempotencyKey is the stable (run, node) key handed to the handler. IdempotencyKey string } From dffa2e843df1c826f5e71d3f791ccc53a08ad009 Mon Sep 17 00:00:00 2001 From: pikann22 Date: Mon, 3 Aug 2026 14:57:11 +0000 Subject: [PATCH 3/3] feat: enhance condition and action handling with error reporting and add tests --- automation.go | 15 ++-- dispatch.go | 30 ++++--- dispatch_test.go | 160 ++++++++++++++++++++++++++++++++++ plugintest/automation_test.go | 40 +++++++++ testing_test.go | 64 ++++++++++++++ wasm_exports.go | 19 +--- 6 files changed, 290 insertions(+), 38 deletions(-) create mode 100644 dispatch_test.go create mode 100644 plugintest/automation_test.go create mode 100644 testing_test.go diff --git a/automation.go b/automation.go index 40d1b62..e48a2b2 100644 --- a/automation.go +++ b/automation.go @@ -46,9 +46,14 @@ type ConditionRequest struct { // 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. +// 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"` + Matched bool `json:"matched"` + Error string `json:"error,omitempty"` } // ActionRequest is the payload a plugin's Action handler receives: the @@ -95,9 +100,6 @@ type ActionHandler func(req *ActionRequest) ActionResult // 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) { - if c.conditions == nil { - c.conditions = make(map[string]ConditionHandler) - } c.conditions[nodeType] = handler } @@ -111,8 +113,5 @@ func (c *Context) Condition(nodeType string, handler ConditionHandler) { // 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) { - if c.actions == nil { - c.actions = make(map[string]ActionHandler) - } c.actions[nodeType] = handler } diff --git a/dispatch.go b/dispatch.go index ad645e9..f0f8a41 100644 --- a/dispatch.go +++ b/dispatch.go @@ -119,22 +119,26 @@ func (d *dispatcher) handleEvent(topic string, payload []byte) { // 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]. +// [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 { - if err := d.init(); err != nil { - return marshalConditionResult(ConditionResult{Matched: false}) - } - var req ConditionRequest if err := unmarshalJSON(payload, &req); err != nil { - return marshalConditionResult(ConditionResult{Matched: false}) + 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}) + return marshalConditionResult(ConditionResult{Matched: false, Error: "no condition handler registered for node type " + req.NodeType}) } return marshalConditionResult(handler(&req)) } @@ -142,19 +146,21 @@ func (d *dispatcher) evaluateCondition(payload []byte) []byte { // 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]. +// [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 { - if err := d.init(); err != nil { - return marshalActionResult(ActionResult{Applied: false, Error: "plugin init failed: " + err.Error()}) - } - 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}) diff --git a/dispatch_test.go b/dispatch_test.go new file mode 100644 index 0000000..8cd7a26 --- /dev/null +++ b/dispatch_test.go @@ -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) + } + }) +} diff --git a/plugintest/automation_test.go b/plugintest/automation_test.go new file mode 100644 index 0000000..24ef877 --- /dev/null +++ b/plugintest/automation_test.go @@ -0,0 +1,40 @@ +package plugintest + +import ( + "testing" + + plugin "github.com/Paca-AI/plugin-sdk-go" +) + +func TestContext_EvaluateCondition(t *testing.T) { + tc := NewContext(t) + tc.PluginContext().Condition("test.high_importance", func(req *plugin.ConditionRequest) plugin.ConditionResult { + return plugin.ConditionResult{Matched: req.Task.Importance >= 5} + }) + + result := tc.EvaluateCondition("test.high_importance", ConditionRequest{ + Task: plugin.TaskSnapshot{Importance: 8}, + }) + if !result.Matched { + t.Fatalf("expected Matched=true for Importance=8, got %+v", result) + } + + result = tc.EvaluateCondition("test.high_importance", ConditionRequest{ + Task: plugin.TaskSnapshot{Importance: 1}, + }) + if result.Matched { + t.Fatalf("expected Matched=false for Importance=1, got %+v", result) + } +} + +func TestContext_RunAction(t *testing.T) { + tc := NewContext(t) + tc.PluginContext().Action("test.mark_done", func(req *plugin.ActionRequest) plugin.ActionResult { + return plugin.ActionResult{Applied: req.IdempotencyKey != ""} + }) + + result := tc.RunAction("test.mark_done", ActionRequest{IdempotencyKey: "run-1/node-2"}) + if !result.Applied { + t.Fatalf("expected Applied=true, got %+v", result) + } +} diff --git a/testing_test.go b/testing_test.go new file mode 100644 index 0000000..8167731 --- /dev/null +++ b/testing_test.go @@ -0,0 +1,64 @@ +package plugin + +import "testing" + +func newTestContext() *Context { + return NewContextForTest( + newWASMDBBackend(), + newWASMKVBackend(), + newWASMCacheBackend(), + newWASMLogBackend(), + newWASMConfigBackend(), + newWASMPermissionBackend(), + ) +} + +func TestDispatchCondition(t *testing.T) { + t.Run("found handler", func(t *testing.T) { + ctx := newTestContext() + ctx.Condition("test.cond", func(req *ConditionRequest) ConditionResult { + return ConditionResult{Matched: req.ProjectID == "proj-1"} + }) + + result, ok := DispatchCondition(ctx, &ConditionRequest{NodeType: "test.cond", ProjectID: "proj-1"}) + if !ok { + t.Fatal("expected a registered handler to be found") + } + if !result.Matched { + t.Fatalf("expected Matched=true, got %+v", result) + } + }) + + t.Run("no handler registered", func(t *testing.T) { + ctx := newTestContext() + _, ok := DispatchCondition(ctx, &ConditionRequest{NodeType: "missing.type"}) + if ok { + t.Fatal("expected no handler to be found for an unregistered node type") + } + }) +} + +func TestDispatchAction(t *testing.T) { + t.Run("found handler", func(t *testing.T) { + ctx := newTestContext() + ctx.Action("test.action", func(req *ActionRequest) ActionResult { + return ActionResult{Applied: req.IdempotencyKey != ""} + }) + + result, ok := DispatchAction(ctx, &ActionRequest{NodeType: "test.action", IdempotencyKey: "k1"}) + if !ok { + t.Fatal("expected a registered handler to be found") + } + if !result.Applied { + t.Fatalf("expected Applied=true, got %+v", result) + } + }) + + t.Run("no handler registered", func(t *testing.T) { + ctx := newTestContext() + _, ok := DispatchAction(ctx, &ActionRequest{NodeType: "missing.type"}) + if ok { + t.Fatal("expected no handler to be found for an unregistered node type") + } + }) +} diff --git a/wasm_exports.go b/wasm_exports.go index 72b2456..c6b4e44 100644 --- a/wasm_exports.go +++ b/wasm_exports.go @@ -41,24 +41,7 @@ func HandleRequest(ptr, length int32) int64 { } payload := wasmSlice(ptr, length) result := globalDispatcher.handleRequest(payload) - if len(result) == 0 { - return 0 - } - // Allocate space in mallocBuffer for the response - outPtr := wasmMalloc(int32(len(result))) - if outPtr == 0 { - return 0 - } - // Copy the result into allocated WASM memory. - out := wasmSlice(outPtr, int32(len(result))) - if len(out) != len(result) { - return 0 - } - copy(out, result) - // Return offset and length combined into int64 - // NOTE: Host MUST copy out the response before calling ResetAllocator, - // which is called after each HandleRequest completes. - return (int64(outPtr) << 32) | int64(len(result)) + return packWASMResult(result) } //go:wasmexport ResetAllocator