feat: workflow {{state.*}} store + REST options.auth GoPlus provider#662
Merged
Conversation
added 2 commits
July 16, 2026 11:54
…us provider
Two generic, reusable gateway extensions (the guardian monitor is the first
consumer; see PLAN_WORKFLOW_STATE_GUARDIAN_MONITORING.md):
1. {{state.*}} — durable, cross-run, per-WORKFLOW state exposed to customCode as
state.get/set/list, backed by a new wfstate:<taskId>:<stateKey> BadgerDB
namespace (scoped by taskId only — isolated per workflow, no user coupling).
Writes no-op to an in-memory scratch in simulation / single-node runs / when
no DB is wired, so nodes:run + workflows:simulate previews never mutate live
state. Cascade-deleted on task teardown. Bound after step vars so a node named
"state" can't shadow it.
2. REST options.auth — a restApi node with config.options.auth.provider: "goplus"
gets a GoPlus signed session token minted + cached server-side from
macros.secrets (goplus_app_key/secret; sha1(app_key+time+app_secret) ->
/v1/token, refreshed ~60s early) and injected as the Authorization header. The
secret never appears in the (client-visible) workflow JSON; keyless GoPlus
fallback when keys are unset. Provider registry starts with GoPlus.
Additive storage (new wfstate: namespace, no migration). New Go tests cover the
state set/get/list roundtrip, cross-run persistence, the simulation no-op guard,
and options.auth parsing. Verified end-to-end: the ava-sdk-js guardian live test
verdict step now passes against a local gateway.
…T options.auth Confirmed live against a local gateway with real GoPlus keys in macros.secrets: options.auth survives create->store->execute, the token mints, and the Authorization header is attached (authed request). Add a Debug log on attach and a Warn on keyless fallback (via the VM logger, since the package globalLogger is nil in the aggregator path).
Contributor
There was a problem hiding this comment.
Pull request overview
Adds two reusable gateway extensions: (1) a durable per-workflow {{state.*}} store surfaced to customCode as state.get/set/list, and (2) REST options.auth provider plumbing starting with GoPlus (server-minted token injected as Authorization, with keyless fallback).
Changes:
- Introduces
wfstate:<taskId>:<stateKey>storage keys + customCode JS binding (state.get/set/list) with simulation/no-DB scratch behavior. - Adds REST
options.auth.provider: goplushandling, including server-side token minting/caching and request header injection. - Updates workflow deletion to cascade-delete
wfstate:keys and extends example configs/tests for the new capabilities.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| core/taskengine/vm_workflow_state_test.go | New Go tests covering workflow state roundtrips/persistence and REST auth provider parsing. |
| core/taskengine/vm_runner_rest.go | Adds GoPlus auth provider parsing + token mint/cache + Authorization header injection. |
| core/taskengine/vm_runner_customcode.go | Adds state.get/set/list JS binding backed by wfstate: keys with scratch-only modes. |
| core/taskengine/schema.go | Defines WorkflowStateKey / WorkflowStatePrefix storage helpers for wfstate: namespace. |
| core/taskengine/engine.go | Cascade-deletes per-workflow wfstate: keys during workflow deletion. |
| config/test.example.yaml | Documents GoPlus macro secrets for signed-token auth. |
| config/gateway.example.yaml | Documents GoPlus macro secrets for signed-token auth. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
3
to
7
| import ( | ||
| "encoding/json" | ||
| "fmt" | ||
| "math/big" | ||
| "reflect" |
Comment on lines
+80
to
+99
| obj.Set("list", func(prefix string) []string { | ||
| out := []string{} | ||
| if scratchOnly { | ||
| for k := range scratch { | ||
| if strings.HasPrefix(k, prefix) { | ||
| out = append(out, k) | ||
| } | ||
| } | ||
| return out | ||
| } | ||
| full := string(WorkflowStatePrefix(taskID)) | ||
| keys, err := vm.db.ListKeys(full + prefix) | ||
| if err != nil { | ||
| return out | ||
| } | ||
| for _, k := range keys { | ||
| out = append(out, strings.TrimPrefix(k, full)) | ||
| } | ||
| return out | ||
| }) |
Comment on lines
+39
to
+42
| // scratch-only when we must not (or cannot) persist. | ||
| scratchOnly := vm == nil || vm.db == nil || taskID == "" || vm.IsSimulation | ||
| scratch := map[string][]byte{} | ||
|
|
Comment on lines
+914
to
+918
| var goplusTokenCache struct { | ||
| sync.RWMutex | ||
| token string | ||
| expires time.Time | ||
| } |
Comment on lines
+932
to
+946
| goplusTokenCache.RLock() | ||
| if goplusTokenCache.token != "" && time.Until(goplusTokenCache.expires) > 60*time.Second { | ||
| token := goplusTokenCache.token | ||
| goplusTokenCache.RUnlock() | ||
| return token | ||
| } | ||
| goplusTokenCache.RUnlock() | ||
|
|
||
| goplusTokenCache.Lock() | ||
| defer goplusTokenCache.Unlock() | ||
| // Re-check under the write lock (another goroutine may have refreshed). | ||
| if goplusTokenCache.token != "" && time.Until(goplusTokenCache.expires) > 60*time.Second { | ||
| return goplusTokenCache.token | ||
| } | ||
|
|
Comment on lines
+954
to
+971
| resp, err := http.Post("https://api.gopluslabs.io/api/v1/token", "application/json", bytes.NewReader(reqBody)) | ||
| if err != nil { | ||
| return "" | ||
| } | ||
| defer resp.Body.Close() | ||
| var parsed struct { | ||
| Code int `json:"code"` | ||
| Result struct { | ||
| AccessToken string `json:"access_token"` | ||
| ExpiresIn int64 `json:"expires_in"` | ||
| } `json:"result"` | ||
| } | ||
| if decodeErr := json.NewDecoder(resp.Body).Decode(&parsed); decodeErr != nil || parsed.Code != 1 || parsed.Result.AccessToken == "" { | ||
| return "" | ||
| } | ||
| goplusTokenCache.token = parsed.Result.AccessToken | ||
| goplusTokenCache.expires = time.Now().Add(time.Duration(parsed.Result.ExpiresIn) * time.Second) | ||
| return parsed.Result.AccessToken |
Comment on lines
+579
to
+592
| // Server-side auth providers (options.auth): mint + attach a provider token so the | ||
| // credential never lives in the workflow JSON. GoPlus first; on "" (keys unset or | ||
| // mint failed) we send no Authorization header — GoPlus still answers keyless. | ||
| if restAuthProvider(node) == "goplus" { | ||
| if token := goplusAuthHeader(); token != "" { | ||
| processedHeaders["Authorization"] = token | ||
| if r.vm.logger != nil { | ||
| r.vm.logger.Debug("REST: attached GoPlus session token (authed)", "stepID", stepID) | ||
| } | ||
| } else if r.vm.logger != nil { | ||
| // Keys unset or mint failed: GoPlus still answers keyless (lower limits). | ||
| r.vm.logger.Warn("REST: GoPlus auth requested but no token minted; falling back to keyless", "stepID", stepID) | ||
| } | ||
| } |
Comment on lines
+4609
to
+4615
| 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) | ||
| } | ||
| } | ||
| } |
- state.list: sort results in both scratch and DB paths so simulation and real runs agree on ordering (Badger iterates sorted; a Go map does not). - state scratch: move from a per-JSProcessor closure to the VM (vm.stateScratch, mutex-guarded) so all customCode steps of one simulation run share it and read-after-write stays coherent across nodes. - GoPlus token cache: key by app_key so a rotated key never reuses a stale token. - goplusAuthHeader: use an http.Client with a 15s timeout (not http.Post), check for a 2xx status, and normalize the token to a "Bearer " prefix defensively. - Add a goplusTokenProvider seam + a REST test asserting the Authorization header is attached when a token is available and left unset for the keyless fallback. - Log the ListKeys error in the wfstate cascade-delete so orphaned state is visible.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two generic, reusable gateway extensions (the guardian monitor is the first
consumer; see PLAN_WORKFLOW_STATE_GUARDIAN_MONITORING.md):
{{state.*}} — durable, cross-run, per-WORKFLOW state exposed to customCode as
state.get/set/list, backed by a new wfstate:: BadgerDB
namespace (scoped by taskId only — isolated per workflow, no user coupling).
Writes no-op to an in-memory scratch in simulation / single-node runs / when
no DB is wired, so nodes:run + workflows:simulate previews never mutate live
state. Cascade-deleted on task teardown. Bound after step vars so a node named
"state" can't shadow it.
REST options.auth — a restApi node with config.options.auth.provider: "goplus"
gets a GoPlus signed session token minted + cached server-side from
macros.secrets (goplus_app_key/secret; sha1(app_key+time+app_secret) ->
/v1/token, refreshed ~60s early) and injected as the Authorization header. The
secret never appears in the (client-visible) workflow JSON; keyless GoPlus
fallback when keys are unset. Provider registry starts with GoPlus.
Additive storage (new wfstate: namespace, no migration). New Go tests cover the
state set/get/list roundtrip, cross-run persistence, the simulation no-op guard,
and options.auth parsing. Verified end-to-end: the ava-sdk-js guardian live test
verdict step now passes against a local gateway.
Confirmed live against a local gateway with real GoPlus keys in macros.secrets:
options.auth survives create->store->execute, the token mints, and the
Authorization header is attached (authed request). Add a Debug log on attach and
a Warn on keyless fallback (via the VM logger, since the package globalLogger is
nil in the aggregator path).