Skip to content

feat: workflow {{state.*}} store + REST options.auth GoPlus provider#662

Merged
chrisli30 merged 3 commits into
stagingfrom
feat/workflow-state-and-rest-auth
Jul 16, 2026
Merged

feat: workflow {{state.*}} store + REST options.auth GoPlus provider#662
chrisli30 merged 3 commits into
stagingfrom
feat/workflow-state-and-rest-auth

Conversation

@chrisli30

Copy link
Copy Markdown
Member
  • feat: {{state.*}} per-workflow state binding + REST options.auth GoPlus 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:: 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.

  • feat: log GoPlus auth attach (Debug) / keyless fallback (Warn) on REST 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).

Chris Li 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).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: goplus handling, 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 thread core/taskengine/vm_runner_customcode.go Outdated
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 thread core/taskengine/vm_runner_rest.go Outdated
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 thread core/taskengine/engine.go
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.
@chrisli30
chrisli30 merged commit bf42329 into staging Jul 16, 2026
18 checks passed
@chrisli30
chrisli30 deleted the feat/workflow-state-and-rest-auth branch July 16, 2026 21:47
chrisli30 pushed a commit that referenced this pull request Jul 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants