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
9 changes: 9 additions & 0 deletions config/gateway.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,15 @@ macros:
sendgrid_key: ""
thegraph_api_key: ""
moralis_api_key: ""
# GoPlus signed-token auth for restApi nodes using `options.auth.provider: goplus`.
# Leave blank to fall back to keyless GoPlus (lower rate limits).
goplus_app_key: ""
goplus_app_secret: ""
# guardian_ruleset — the tunable verdict knobs the guardian customCode reads via
# {{apContext.configVars.guardian_ruleset}} (bare-injected as a JS object). MUST be
# a valid JSON object and MUST be set (an unresolved template in a customCode source
# hard-fails the node). Single-quote it so the inner double quotes survive YAML.
guardian_ruleset: '{"weak":["honeypot_related_address","blacklist_doubt"]}'

# Optional: post-execution AI summarization. Disabled by default in dev.
notifications:
Expand Down
7 changes: 7 additions & 0 deletions config/test.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,13 @@ macros:
sendgrid_key: ""
thegraph_api_key: ""
moralis_api_key: ""
# GoPlus signed-token auth for restApi nodes using `options.auth.provider: goplus`.
# Leave blank to fall back to keyless GoPlus (lower rate limits).
goplus_app_key: ""
goplus_app_secret: ""
# guardian_ruleset — tunable verdict knobs read via {{apContext.configVars.guardian_ruleset}}
# (bare-injected as a JS object; must be valid JSON and must be set).
guardian_ruleset: '{"weak":["honeypot_related_address","blacklist_doubt"]}'

# Optional: post-execution AI summarization. Disabled by default in tests.
notifications:
Expand Down
13 changes: 13 additions & 0 deletions core/taskengine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -4603,6 +4603,19 @@ func (n *Engine) DeleteWorkflowByUser(user *model.User, taskID string) (*avsprot
// checkpoint/wake — and any operator internal trigger — don't outlive the workflow.
n.gcDurableStateForTask(taskID)

// Cascade-delete this workflow's durable per-run state ({{state.*}} /
// wfstate:<taskId>) so it doesn't outlive the task (scoped by taskID, so this
// touches only this workflow's namespace).
if stateKeys, listErr := n.db.ListKeys(string(WorkflowStatePrefix(taskID))); listErr == nil {
for _, k := range stateKeys {
if delErr := n.db.Delete([]byte(k)); delErr != nil {
n.logger.Warn("failed to delete workflow state key", "key", k, "error", delErr)
}
}
} else {
n.logger.Warn("failed to list workflow state keys for cascade delete; state may be orphaned", "task_id", taskID, "error", listErr)
}

n.logger.Info("📢 Starting operator notifications", "task_id", taskID)
n.notifyOperatorsTaskOperation(taskID, avsproto.MessageOp_DeleteTask)
n.logger.Info("✅ Delete task operation completed", "task_id", taskID)
Expand Down
17 changes: 17 additions & 0 deletions core/taskengine/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,23 @@ func WalletStorageKey(chainID int64, owner common.Address, smartWalletAddress st
)
}

// WorkflowStateKey returns the key for one entry of a workflow's durable,
// cross-run mutable state — the `{{state.*}}` store exposed to customCode.
//
// Scoped ONLY by taskID (no owner, no chain): every workflow has an isolated
// namespace, so a user with several monitoring workflows never has their state
// collide, and deleting a workflow can wipe exactly its state (see
// WorkflowStatePrefix). `stateKey` is a client-defined opaque string.
func WorkflowStateKey(taskID, stateKey string) []byte {
return []byte(fmt.Sprintf("wfstate:%s:%s", taskID, stateKey))
}

// WorkflowStatePrefix returns the scan/cascade-delete prefix for all `{{state.*}}`
// entries of one workflow.
func WorkflowStatePrefix(taskID string) []byte {
return []byte(fmt.Sprintf("wfstate:%s:", taskID))
}

// WalletBySaltKey returns the secondary index key that maps a
// (chainID, owner, factory, salt) tuple to its current canonical wallet
// address.
Expand Down
36 changes: 36 additions & 0 deletions core/taskengine/vm.go
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,42 @@ type VM struct {
// the executor reads PendingSuspend() to checkpoint + register the wake instead
// of finishing. nil for a normal run.
suspend *SuspendRequest

// stateScratch backs the {{state.*}} binding when it must NOT persist — in
// simulation, single-node runs (no task id), or when no DB is wired. It lives
// on the VM (not per-JSProcessor) so all customCode steps of one run share it,
// keeping read-after-write coherent across nodes just like the DB-backed path.
// Guarded by mu. Never flushed to storage.
stateScratch map[string][]byte
}

// scratchGet/scratchSet/scratchList back the {{state.*}} binding's non-persistent
// mode (see stateScratch). All are mutex-guarded so parallel customCode steps are safe.
func (v *VM) scratchGet(key string) []byte {
v.mu.Lock()
defer v.mu.Unlock()
return v.stateScratch[key]
}

func (v *VM) scratchSet(key string, value []byte) {
v.mu.Lock()
defer v.mu.Unlock()
if v.stateScratch == nil {
v.stateScratch = make(map[string][]byte)
}
v.stateScratch[key] = value
}

func (v *VM) scratchList(prefix string) []string {
v.mu.Lock()
defer v.mu.Unlock()
out := make([]string, 0, len(v.stateScratch))
for k := range v.stateScratch {
if strings.HasPrefix(k, prefix) {
out = append(out, k)
}
}
return out
}

func NewVM() *VM {
Expand Down
94 changes: 94 additions & 0 deletions core/taskengine/vm_runner_customcode.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package taskengine

import (
"encoding/json"
"fmt"
"math/big"
"reflect"
Comment on lines 3 to 7
"regexp"
"sort"
"strings"
"time"

Expand All @@ -16,6 +18,90 @@ import (
avsproto "github.com/AvaProtocol/EigenLayer-AVS/protobuf"
)

// installStateBinding exposes `state.get/set/list` to customCode JS, backed by the
// per-workflow `wfstate:<taskId>` namespace (WorkflowStateKey). This is the durable,
// cross-run, client-defined state store — the `{{state.*}}` capability.
//
// - state.get(key) -> the stored JSON value, or undefined
// - state.set(key, value) -> persists arbitrary JSON under this workflow's namespace
// - state.list(prefix) -> the stateKeys (prefix included) currently stored
//
// Writes never hit the DB in simulation, for a single-node run (no task id), or when
// no DB is wired: those use an in-memory scratch map so read-after-write still works
// within one run without mutating real state. Bind this AFTER the step vars so a node
// accidentally named "state" can't shadow it.
func installStateBinding(jsvm *goja.Runtime, vm *VM) {
if vm == nil {
return
}
obj := jsvm.NewObject()

taskID := vm.GetTaskId()
// scratch-only when we must not (or cannot) persist. The scratch lives on the VM
// (vm.scratch*), so all customCode steps of one run share it.
scratchOnly := vm.db == nil || taskID == "" || vm.IsSimulation

obj.Set("get", func(key string) interface{} {
if key == "" {
return goja.Undefined()
}
var raw []byte
if scratchOnly {
raw = vm.scratchGet(key)
} else if b, err := vm.db.GetKey(WorkflowStateKey(taskID, key)); err == nil {
raw = b
}
if raw == nil {
return goja.Undefined()
}
var value interface{}
if err := json.Unmarshal(raw, &value); err != nil {
return goja.Undefined()
}
return value
})

obj.Set("set", func(key string, value interface{}) {
if key == "" {
return
}
encoded, err := json.Marshal(value)
if err != nil {
return
}
if scratchOnly {
vm.scratchSet(key, encoded)
return
}
if setErr := vm.db.Set(WorkflowStateKey(taskID, key), encoded); setErr != nil && vm.logger != nil {
vm.logger.Warn("state.set failed", "key", key, "error", setErr)
}
})

obj.Set("list", func(prefix string) []string {
var out []string
if scratchOnly {
out = vm.scratchList(prefix)
} else {
full := string(WorkflowStatePrefix(taskID))
keys, err := vm.db.ListKeys(full + prefix)
if err != nil {
return []string{}
}
out = make([]string, 0, len(keys))
for _, k := range keys {
out = append(out, strings.TrimPrefix(k, full))
}
}
// Deterministic order regardless of backing store (Badger iterates sorted;
// the scratch map does not) so simulation and real runs agree.
sort.Strings(out)
return out
})

jsvm.Set("state", obj)
}

type JSProcessor struct {
*CommonProcessor
jsvm *goja.Runtime
Expand Down Expand Up @@ -73,6 +159,10 @@ func NewJSProcessor(vm *VM) *JSProcessor {
}
}

// Expose {{state.*}} — durable per-workflow state. Bound last so a step var
// accidentally named "state" cannot shadow it.
installStateBinding(r.jsvm, vm)

return &r
}

Expand Down Expand Up @@ -130,6 +220,10 @@ func NewJSProcessorWithIsolatedVars(vm *VM, isolatedVars map[string]any) *JSProc
}
}

// Expose {{state.*}} — durable per-workflow state. Bound last so an isolated var
// accidentally named "state" cannot shadow it.
installStateBinding(r.jsvm, vm)

return &r
}

Expand Down
Loading
Loading