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
15 changes: 15 additions & 0 deletions plugin/external/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,21 @@ func createTypedConfigRequest(descriptor *pb.ContractDescriptor, cfg map[string]
}
return s, nil, nil
}
// Contracts that declare a typed Mode (STRICT_PROTO or
// PROTO_WITH_LEGACY_STRUCT) but leave ConfigMessage empty have no
// per-instance config schema — primarily input-only steps like
// step.eventbus.ack/publish/consume where data flows through the
// InputMessage proto, but also applies to any contract Kind that
// legitimately omits a config schema. Encode cfg as legacy struct
// only; typed payload is nil. The plugin's typed factory reads data
// from the input message (or other typed payload), not from config.
if descriptor.ConfigMessage == "" {
s, err := mapToStruct(cfg)
if err != nil {
return nil, nil, fmt.Errorf("encode config as Struct (no typed config schema): %w", err)
}
return s, nil, nil
}
// Strip engine-internal "_"-prefix keys before proto decode. STRICT_PROTO
// and PROTO_WITH_LEGACY_STRUCT modules use protojson with DiscardUnknown
// = false (convert.go:62), which rejects engine internals like
Expand Down
42 changes: 42 additions & 0 deletions plugin/external/adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1022,6 +1022,48 @@ func TestCreateTypedConfigRequestStripsInternalKeysForStrictProtoStep(t *testing
}
}

// TestCreateTypedConfigRequestEmptyConfigMessageStrictProto covers
// contracts that declare STRICT_PROTO with InputMessage + OutputMessage but
// no ConfigMessage (input-only steps like step.eventbus.ack /
// step.eventbus.publish). The engine must NOT attempt to encode an
// unnamed typed proto; typed payload is nil, legacy struct mirrors cfg
// (nil cfg → nil legacy via mapToStruct(nil); non-nil cfg → populated
// struct).
func TestCreateTypedConfigRequestEmptyConfigMessageStrictProto(t *testing.T) {
descriptor := &pb.ContractDescriptor{
Kind: pb.ContractKind_CONTRACT_KIND_STEP,
StepType: "step.eventbus.ack",
Mode: pb.ContractMode_CONTRACT_MODE_STRICT_PROTO,
InputMessage: "workflow.plugin.eventbus.v1.AckRequest",
OutputMessage: "workflow.plugin.eventbus.v1.AckResponse",
// ConfigMessage intentionally empty — step has no per-instance
// config schema; data flows via the input message.
}
// nil cfg — mapToStruct(nil) returns nil; legacy is permitted to be nil.
legacy, typed, err := createTypedConfigRequest(descriptor, nil, nil)
if err != nil {
t.Fatalf("createTypedConfigRequest with nil cfg + empty ConfigMessage: %v", err)
}
if typed != nil {
t.Fatalf("expected nil typed *anypb.Any for input-only step contract; got %v", typed)
}
if legacy != nil {
t.Fatalf("expected nil legacy struct for nil cfg; got %v", legacy.Fields)
}
// Non-nil cfg — fields populated into legacy struct; typed still nil.
cfg := map[string]any{"timeout_ms": float64(5000)}
legacy2, typed2, err := createTypedConfigRequest(descriptor, cfg, nil)
if err != nil {
t.Fatalf("createTypedConfigRequest with cfg + empty ConfigMessage: %v", err)
}
if typed2 != nil {
t.Fatalf("expected nil typed *anypb.Any for input-only step contract; got %v", typed2)
}
if legacy2 == nil || legacy2.Fields["timeout_ms"] == nil {
t.Fatalf("expected legacy struct with timeout_ms populated; got %v", legacy2)
}
}

// TestCreateTypedConfigRequestRetainsInternalKeysInLegacyStruct asserts the
// legacy-struct path keeps "_"-prefix keys on its *structpb.Struct payload.
// Legacy modules consume "_config_dir" at the plugin side to resolve filesystem-
Expand Down
Loading