From 0858a6e0ec26647bd8e34c8549ba53bc081b260f Mon Sep 17 00:00:00 2001 From: Tejaswi Nadahalli Date: Thu, 16 Jul 2026 14:57:29 +0200 Subject: [PATCH 1/3] workflows/host: distinguish transient unavailable runner from missing runner subscribe() previously returned a generic "cannot find a runner" error whenever no runner matched a trigger's requirements, conflating two cases: (1) no runner of the required type is configured at all (permanent misconfiguration), and (2) a runner of the right type exists but cannot currently satisfy the requirement, e.g. a capability that gates the runner is temporarily unavailable or not yet activated across the DON (transient). Case (2) now returns a typed ErrRunnerUnavailable so callers can hold and retry rather than failing workflow initialization outright; case (1) keeps the existing hard error. Adds a generated HandlesRequirements helper (a runner-type check) to tell the two apart. Lets the confidential-workflows engine degrade gracefully while the confidential-workflows capability rolls out DON-wide, instead of error-storming subscribe failures on nodes that don't yet have it. --- .../host/requirement_selecting_module.go | 27 +++++++++++++++++++ .../host/requirement_selecting_module_test.go | 8 +++++- .../requirements_helper.go.tmpl | 24 +++++++++++++++++ pkg/workflows/host/requirements_helper_gen.go | 22 +++++++++++++++ 4 files changed, 80 insertions(+), 1 deletion(-) diff --git a/pkg/workflows/host/requirement_selecting_module.go b/pkg/workflows/host/requirement_selecting_module.go index 3ce23f7e49..af4ee0885e 100644 --- a/pkg/workflows/host/requirement_selecting_module.go +++ b/pkg/workflows/host/requirement_selecting_module.go @@ -9,6 +9,13 @@ import ( "github.com/smartcontractkit/chainlink-protos/cre/go/sdk" ) +// ErrRunnerUnavailable indicates that a trigger's requirements are handled by a +// known runner type, but no runner can currently satisfy them, e.g. a capability +// that gates the runner is temporarily unavailable or not yet activated across the +// DON. It is distinct from a permanent "no such runner is configured": callers may +// treat it as transient and retry rather than failing workflow initialization. +var ErrRunnerUnavailable = errors.New("no runner can currently satisfy the trigger requirements") + type ModuleAndHandler struct { Module RequirementsHandler @@ -109,6 +116,13 @@ func (r *requirementSelectingModule) subscribe(ctx context.Context, request *sdk } } if !matched { + if r.runnerTypeAvailable(sub.Requirements) { + // A runner of the right type exists but cannot currently satisfy the + // requirements (e.g. a gated or temporarily unavailable capability). + // Surface this as transient so callers can hold and retry rather than + // failing workflow initialization outright. + return nil, fmt.Errorf("%w for trigger %d", ErrRunnerUnavailable, i) + } return nil, fmt.Errorf("cannot find a runner that can satisfy the requirements for trigger %d", i) } } @@ -116,6 +130,19 @@ func (r *requirementSelectingModule) subscribe(ctx context.Context, request *sdk return result, nil } +// runnerTypeAvailable reports whether any module is the right type of runner for req +// (it handles every requirement field), even if none can currently satisfy the +// requirement values. Used to distinguish a transient ErrRunnerUnavailable from a +// permanent "no such runner". +func (r *requirementSelectingModule) runnerTypeAvailable(req *sdk.Requirements) bool { + for _, m := range r.modules { + if HandlesRequirements(m.RequirementsHandler, req) { + return true + } + } + return false +} + func (r *requirementSelectingModule) trigger(ctx context.Context, request *sdk.ExecuteRequest, handler ExecutionHelper) (*sdk.ExecutionResult, error) { trigger := request.GetTrigger() if val, cached := r.cache.Load(trigger.Id); cached { diff --git a/pkg/workflows/host/requirement_selecting_module_test.go b/pkg/workflows/host/requirement_selecting_module_test.go index f91f539046..63be90dcc4 100644 --- a/pkg/workflows/host/requirement_selecting_module_test.go +++ b/pkg/workflows/host/requirement_selecting_module_test.go @@ -237,7 +237,10 @@ func TestRequirementSelectingModule_Execute(t *testing.T) { _, err := m.Execute(t.Context(), subscribeRequest(), nil) require.Error(t, err) - assert.Contains(t, err.Error(), "cannot find a runner that can satisfy the requirements") + // A Tee runner exists (add has a Tee handler) but it cannot currently satisfy + // the requirement, so this is the transient, retryable case, not a permanent + // "no such runner" misconfiguration. + assert.ErrorIs(t, err, ErrRunnerUnavailable) }) t.Run("subscribe skips non-matching and selects later additional", func(t *testing.T) { @@ -541,7 +544,10 @@ func TestRequirementSelectingModule_TriggerCache(t *testing.T) { _, err := m.Execute(t.Context(), subscribeRequest(), nil) require.Error(t, err) + // No module handles a Tee requirement at all, so this is the permanent + // "no such runner" case, distinct from the transient ErrRunnerUnavailable. assert.Contains(t, err.Error(), "cannot find a runner") + assert.NotErrorIs(t, err, ErrRunnerUnavailable) }) } diff --git a/pkg/workflows/host/requirements_gen/requirements_helper.go.tmpl b/pkg/workflows/host/requirements_gen/requirements_helper.go.tmpl index da55611a42..57632a166d 100644 --- a/pkg/workflows/host/requirements_gen/requirements_helper.go.tmpl +++ b/pkg/workflows/host/requirements_gen/requirements_helper.go.tmpl @@ -33,3 +33,27 @@ func CheckRequirements(ctx context.Context, handler RequirementsHandler, req *sd return true } + +// HandlesRequirements reports whether handler is the right *type* of runner for req: +// it has a (non-nil) callback for every non-nil field in req, regardless of whether +// those callbacks would be satisfied. It lets callers distinguish a transient +// "a runner of this type exists but cannot currently satisfy the requirements" +// (e.g. a capability that gates the runner is unavailable or not yet activated +// across the DON) from a permanent "no runner of this type is configured at all". +func HandlesRequirements(handler RequirementsHandler, req *sdk.Requirements) bool { + if req == nil { + return true + } + + if len(req.ProtoReflect().GetUnknown()) != 0 { + return false + } + +{{range .Fields}} + if req.{{.Name}} != nil && handler.{{.Name}} == nil { + return false + } +{{end}} + + return true +} diff --git a/pkg/workflows/host/requirements_helper_gen.go b/pkg/workflows/host/requirements_helper_gen.go index d9e5140f34..bb0e48ff78 100644 --- a/pkg/workflows/host/requirements_helper_gen.go +++ b/pkg/workflows/host/requirements_helper_gen.go @@ -35,3 +35,25 @@ func CheckRequirements(ctx context.Context, handler RequirementsHandler, req *sd return true } + +// HandlesRequirements reports whether handler is the right *type* of runner for req: +// it has a (non-nil) callback for every non-nil field in req, regardless of whether +// those callbacks would be satisfied. It lets callers distinguish a transient +// "a runner of this type exists but cannot currently satisfy the requirements" +// (e.g. a capability that gates the runner is unavailable or not yet activated +// across the DON) from a permanent "no runner of this type is configured at all". +func HandlesRequirements(handler RequirementsHandler, req *sdk.Requirements) bool { + if req == nil { + return true + } + + if len(req.ProtoReflect().GetUnknown()) != 0 { + return false + } + + if req.Tee != nil && handler.Tee == nil { + return false + } + + return true +} From 0d2b4e4180eedab9729a47f98f967444fbf51135 Mon Sep 17 00:00:00 2001 From: Tejaswi Nadahalli Date: Thu, 16 Jul 2026 16:05:55 +0200 Subject: [PATCH 2/3] workflows/host: trim verbose comments --- .../host/requirement_selecting_module.go | 17 ++++------------- .../host/requirement_selecting_module_test.go | 5 ----- .../requirements_helper.go.tmpl | 9 +++------ pkg/workflows/host/requirements_helper_gen.go | 9 +++------ 4 files changed, 10 insertions(+), 30 deletions(-) diff --git a/pkg/workflows/host/requirement_selecting_module.go b/pkg/workflows/host/requirement_selecting_module.go index af4ee0885e..93b7555ec5 100644 --- a/pkg/workflows/host/requirement_selecting_module.go +++ b/pkg/workflows/host/requirement_selecting_module.go @@ -9,11 +9,8 @@ import ( "github.com/smartcontractkit/chainlink-protos/cre/go/sdk" ) -// ErrRunnerUnavailable indicates that a trigger's requirements are handled by a -// known runner type, but no runner can currently satisfy them, e.g. a capability -// that gates the runner is temporarily unavailable or not yet activated across the -// DON. It is distinct from a permanent "no such runner is configured": callers may -// treat it as transient and retry rather than failing workflow initialization. +// ErrRunnerUnavailable means a runner of the required type exists but cannot yet +// satisfy the requirements (e.g. a gated capability); callers may retry, not fail. var ErrRunnerUnavailable = errors.New("no runner can currently satisfy the trigger requirements") type ModuleAndHandler struct { @@ -117,10 +114,6 @@ func (r *requirementSelectingModule) subscribe(ctx context.Context, request *sdk } if !matched { if r.runnerTypeAvailable(sub.Requirements) { - // A runner of the right type exists but cannot currently satisfy the - // requirements (e.g. a gated or temporarily unavailable capability). - // Surface this as transient so callers can hold and retry rather than - // failing workflow initialization outright. return nil, fmt.Errorf("%w for trigger %d", ErrRunnerUnavailable, i) } return nil, fmt.Errorf("cannot find a runner that can satisfy the requirements for trigger %d", i) @@ -130,10 +123,8 @@ func (r *requirementSelectingModule) subscribe(ctx context.Context, request *sdk return result, nil } -// runnerTypeAvailable reports whether any module is the right type of runner for req -// (it handles every requirement field), even if none can currently satisfy the -// requirement values. Used to distinguish a transient ErrRunnerUnavailable from a -// permanent "no such runner". +// runnerTypeAvailable reports whether any module handles req's requirement types, +// even if it can't currently satisfy them: a present-but-unavailable runner vs none. func (r *requirementSelectingModule) runnerTypeAvailable(req *sdk.Requirements) bool { for _, m := range r.modules { if HandlesRequirements(m.RequirementsHandler, req) { diff --git a/pkg/workflows/host/requirement_selecting_module_test.go b/pkg/workflows/host/requirement_selecting_module_test.go index 63be90dcc4..83e96feb15 100644 --- a/pkg/workflows/host/requirement_selecting_module_test.go +++ b/pkg/workflows/host/requirement_selecting_module_test.go @@ -237,9 +237,6 @@ func TestRequirementSelectingModule_Execute(t *testing.T) { _, err := m.Execute(t.Context(), subscribeRequest(), nil) require.Error(t, err) - // A Tee runner exists (add has a Tee handler) but it cannot currently satisfy - // the requirement, so this is the transient, retryable case, not a permanent - // "no such runner" misconfiguration. assert.ErrorIs(t, err, ErrRunnerUnavailable) }) @@ -544,8 +541,6 @@ func TestRequirementSelectingModule_TriggerCache(t *testing.T) { _, err := m.Execute(t.Context(), subscribeRequest(), nil) require.Error(t, err) - // No module handles a Tee requirement at all, so this is the permanent - // "no such runner" case, distinct from the transient ErrRunnerUnavailable. assert.Contains(t, err.Error(), "cannot find a runner") assert.NotErrorIs(t, err, ErrRunnerUnavailable) }) diff --git a/pkg/workflows/host/requirements_gen/requirements_helper.go.tmpl b/pkg/workflows/host/requirements_gen/requirements_helper.go.tmpl index 57632a166d..f822cc849a 100644 --- a/pkg/workflows/host/requirements_gen/requirements_helper.go.tmpl +++ b/pkg/workflows/host/requirements_gen/requirements_helper.go.tmpl @@ -34,12 +34,9 @@ func CheckRequirements(ctx context.Context, handler RequirementsHandler, req *sd return true } -// HandlesRequirements reports whether handler is the right *type* of runner for req: -// it has a (non-nil) callback for every non-nil field in req, regardless of whether -// those callbacks would be satisfied. It lets callers distinguish a transient -// "a runner of this type exists but cannot currently satisfy the requirements" -// (e.g. a capability that gates the runner is unavailable or not yet activated -// across the DON) from a permanent "no runner of this type is configured at all". +// HandlesRequirements reports whether handler covers every non-nil field in req (the +// right runner type), regardless of whether those callbacks pass. Distinguishes a +// present-but-unavailable runner from a missing one. func HandlesRequirements(handler RequirementsHandler, req *sdk.Requirements) bool { if req == nil { return true diff --git a/pkg/workflows/host/requirements_helper_gen.go b/pkg/workflows/host/requirements_helper_gen.go index bb0e48ff78..73099b9c0b 100644 --- a/pkg/workflows/host/requirements_helper_gen.go +++ b/pkg/workflows/host/requirements_helper_gen.go @@ -36,12 +36,9 @@ func CheckRequirements(ctx context.Context, handler RequirementsHandler, req *sd return true } -// HandlesRequirements reports whether handler is the right *type* of runner for req: -// it has a (non-nil) callback for every non-nil field in req, regardless of whether -// those callbacks would be satisfied. It lets callers distinguish a transient -// "a runner of this type exists but cannot currently satisfy the requirements" -// (e.g. a capability that gates the runner is unavailable or not yet activated -// across the DON) from a permanent "no runner of this type is configured at all". +// HandlesRequirements reports whether handler covers every non-nil field in req (the +// right runner type), regardless of whether those callbacks pass. Distinguishes a +// present-but-unavailable runner from a missing one. func HandlesRequirements(handler RequirementsHandler, req *sdk.Requirements) bool { if req == nil { return true From e476ca412f83a76e38ddd73eaab9f4da7da786f2 Mon Sep 17 00:00:00 2001 From: Tejaswi Nadahalli Date: Thu, 16 Jul 2026 16:30:39 +0200 Subject: [PATCH 3/3] workflows/host: add unit tests for HandlesRequirements --- .../host/requirements_helper_gen_test.go | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/pkg/workflows/host/requirements_helper_gen_test.go b/pkg/workflows/host/requirements_helper_gen_test.go index 68bb23b3d7..ed53178b25 100644 --- a/pkg/workflows/host/requirements_helper_gen_test.go +++ b/pkg/workflows/host/requirements_helper_gen_test.go @@ -46,3 +46,38 @@ func Test_CheckRequirements(t *testing.T) { assert.True(t, CheckRequirements(context.Background(), handler, req)) }) } + +func Test_HandlesRequirements(t *testing.T) { + t.Parallel() + t.Run("nil req always passes", func(t *testing.T) { + assert.True(t, HandlesRequirements(RequirementsHandler{}, nil)) + }) + + t.Run("no fields always passes", func(t *testing.T) { + assert.True(t, HandlesRequirements(RequirementsHandler{}, &sdk.Requirements{})) + }) + + t.Run("unknown proto fields", func(t *testing.T) { + // Encode a field number (99) unknown to Requirements so proto.Unmarshal + // preserves it as unknown bytes. + b := protowire.AppendTag(nil, 99, protowire.VarintType) + b = protowire.AppendVarint(b, 1) + req := &sdk.Requirements{} + require.NoError(t, proto.Unmarshal(b, req)) + + assert.False(t, HandlesRequirements(RequirementsHandler{}, req)) + }) + + t.Run("required field with nil handler returns false", func(t *testing.T) { + req := &sdk.Requirements{Tee: &sdk.Tee{}} + assert.False(t, HandlesRequirements(RequirementsHandler{}, req)) + }) + + t.Run("handler present passes regardless of callback result", func(t *testing.T) { + req := &sdk.Requirements{Tee: &sdk.Tee{}} + // HandlesRequirements only checks coverage, not whether the callback passes, + // so a handler that would fail CheckRequirements still handles the request. + handler := RequirementsHandler{Tee: func(context.Context, *sdk.Tee) bool { return false }} + assert.True(t, HandlesRequirements(handler, req)) + }) +}