diff --git a/docs/plans/2026-05-13-strict-contracts-design.md b/docs/plans/2026-05-13-strict-contracts-design.md new file mode 100644 index 0000000..3a40f15 --- /dev/null +++ b/docs/plans/2026-05-13-strict-contracts-design.md @@ -0,0 +1,58 @@ +# Strict-Contracts Adoption — workflow-plugin-websocket + +**Date:** 2026-05-13 +**Issue:** #5 +**Branch:** feat/issue-5-strict-contracts + +## Summary + +Add strict-proto contract support to workflow-plugin-websocket, enabling the +workflow engine to validate plugin messages at the gRPC boundary using typed +protobuf messages instead of `map[string]any` dispatch. + +## Surface + +| Kind | Type | Message(s) | +|---------|---------------|-------------------------------------------| +| module | ws.server | WSServerConfig | +| step | step.ws_send | WSSendConfig / WSSendInput / WSSendOutput | +| step | step.ws_close | WSCloseConfig / WSCloseInput / WSCloseOutput | +| trigger | websocket | WebSocketTriggerConfig | + +## Files Added + +- `proto/websocket/v1/websocket.proto` — source of truth for all typed messages +- `gen/websocket.pb.go` — generated from proto (do not hand-edit) +- `internal/contracts.go` — ContractRegistry wired to wsPlugin +- `plugin.contracts.json` — static contract manifest read by wfctl --strict-contracts + +## Design Decisions + +1. **Separate Config/Input/Output per step** — follows tournament/worldengine pattern + (not the shared SalesforceStepInput flattening), because ws_send and ws_close have + distinct, small, typed fields where separate messages are cleaner. + +2. **Trigger has an empty config message** — `WebSocketTriggerConfig` has no fields; + the trigger attaches to the global hub automatically. An empty message is the + correct approach (not omitting the config contract) so wfctl can still validate + that no unexpected config keys are passed. + +3. **WSSendInput/WSCloseInput use Struct** — runtime inputs beyond config fields + are represented as `google.protobuf.Struct data = 1;` matching the pattern used + by tournament and worldsim, since the workflow engine populates step input from + trigger data and prior step outputs (free-form). + +## Assumptions + +- The workflow engine at v0.51.7 supports `CONTRACT_KIND_TRIGGER` (verified in plugin.pb.go). +- `protodesc.ToFileDescriptorProto` is the correct way to register the file descriptor + (matches all precedent plugins). +- The `wsPlugin` type in `internal/plugin.go` is the receiver for `ContractRegistry()`. +- No changes to step execution logic are required — strict contracts only add type + metadata at the plugin boundary; runtime behavior is unchanged. + +## Rollback + +No runtime behavior changed. The `plugin.contracts.json` and `internal/contracts.go` +are additive. To roll back: revert the commit and rebuild. The engine falls back to +legacy struct mode if the plugin does not implement `ContractProvider`. diff --git a/gen/websocket.pb.go b/gen/websocket.pb.go new file mode 100644 index 0000000..f1259d0 --- /dev/null +++ b/gen/websocket.pb.go @@ -0,0 +1,541 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v7.34.1 +// source: websocket.proto + +package websocketv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + structpb "google.golang.org/protobuf/types/known/structpb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// WSServerConfig is the typed config for the ws.server module. +// Fields mirror the internal wsServerModule config keys (camelCase in YAML). +type WSServerConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + // path is the HTTP path for the WebSocket upgrade endpoint (default: /ws). + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + // max_connections is the maximum number of concurrent WebSocket connections (default: 1000). + MaxConnections int32 `protobuf:"varint,2,opt,name=max_connections,json=maxConnections,proto3" json:"max_connections,omitempty"` + // ping_interval is the duration between server-initiated pings (e.g. "30s"). Default: 30s. + PingInterval string `protobuf:"bytes,3,opt,name=ping_interval,json=pingInterval,proto3" json:"ping_interval,omitempty"` + // pong_wait is the time to wait for a pong reply before disconnecting (e.g. "60s"). + PongWait string `protobuf:"bytes,4,opt,name=pong_wait,json=pongWait,proto3" json:"pong_wait,omitempty"` + // max_message_size is the maximum inbound message size in bytes (default: 65536). + MaxMessageSize int64 `protobuf:"varint,5,opt,name=max_message_size,json=maxMessageSize,proto3" json:"max_message_size,omitempty"` + // auth_required, when true, requires an authenticated session before accepting connections. + AuthRequired bool `protobuf:"varint,6,opt,name=auth_required,json=authRequired,proto3" json:"auth_required,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WSServerConfig) Reset() { + *x = WSServerConfig{} + mi := &file_websocket_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WSServerConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WSServerConfig) ProtoMessage() {} + +func (x *WSServerConfig) ProtoReflect() protoreflect.Message { + mi := &file_websocket_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WSServerConfig.ProtoReflect.Descriptor instead. +func (*WSServerConfig) Descriptor() ([]byte, []int) { + return file_websocket_proto_rawDescGZIP(), []int{0} +} + +func (x *WSServerConfig) GetPath() string { + if x != nil { + return x.Path + } + return "" +} + +func (x *WSServerConfig) GetMaxConnections() int32 { + if x != nil { + return x.MaxConnections + } + return 0 +} + +func (x *WSServerConfig) GetPingInterval() string { + if x != nil { + return x.PingInterval + } + return "" +} + +func (x *WSServerConfig) GetPongWait() string { + if x != nil { + return x.PongWait + } + return "" +} + +func (x *WSServerConfig) GetMaxMessageSize() int64 { + if x != nil { + return x.MaxMessageSize + } + return 0 +} + +func (x *WSServerConfig) GetAuthRequired() bool { + if x != nil { + return x.AuthRequired + } + return false +} + +// WSSendConfig is the typed config for step.ws_send. +type WSSendConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + // connection_id is the UUID of the WebSocket connection to send the message to. + ConnectionId string `protobuf:"bytes,1,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"` + // message is the text payload to deliver. + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WSSendConfig) Reset() { + *x = WSSendConfig{} + mi := &file_websocket_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WSSendConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WSSendConfig) ProtoMessage() {} + +func (x *WSSendConfig) ProtoReflect() protoreflect.Message { + mi := &file_websocket_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WSSendConfig.ProtoReflect.Descriptor instead. +func (*WSSendConfig) Descriptor() ([]byte, []int) { + return file_websocket_proto_rawDescGZIP(), []int{1} +} + +func (x *WSSendConfig) GetConnectionId() string { + if x != nil { + return x.ConnectionId + } + return "" +} + +func (x *WSSendConfig) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +// WSSendInput carries optional runtime overrides for step.ws_send. +type WSSendInput struct { + state protoimpl.MessageState `protogen:"open.v1"` + Data *structpb.Struct `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WSSendInput) Reset() { + *x = WSSendInput{} + mi := &file_websocket_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WSSendInput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WSSendInput) ProtoMessage() {} + +func (x *WSSendInput) ProtoReflect() protoreflect.Message { + mi := &file_websocket_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WSSendInput.ProtoReflect.Descriptor instead. +func (*WSSendInput) Descriptor() ([]byte, []int) { + return file_websocket_proto_rawDescGZIP(), []int{2} +} + +func (x *WSSendInput) GetData() *structpb.Struct { + if x != nil { + return x.Data + } + return nil +} + +// WSSendOutput is the result of step.ws_send. +type WSSendOutput struct { + state protoimpl.MessageState `protogen:"open.v1"` + // sent is true when the message was delivered to the connection. + Sent bool `protobuf:"varint,1,opt,name=sent,proto3" json:"sent,omitempty"` + // error is populated when the send failed (ws.server not initialized, missing connectionId). + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WSSendOutput) Reset() { + *x = WSSendOutput{} + mi := &file_websocket_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WSSendOutput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WSSendOutput) ProtoMessage() {} + +func (x *WSSendOutput) ProtoReflect() protoreflect.Message { + mi := &file_websocket_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WSSendOutput.ProtoReflect.Descriptor instead. +func (*WSSendOutput) Descriptor() ([]byte, []int) { + return file_websocket_proto_rawDescGZIP(), []int{3} +} + +func (x *WSSendOutput) GetSent() bool { + if x != nil { + return x.Sent + } + return false +} + +func (x *WSSendOutput) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +// WSCloseConfig is the typed config for step.ws_close. +type WSCloseConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + // connection_id is the UUID of the WebSocket connection to close. + ConnectionId string `protobuf:"bytes,1,opt,name=connection_id,json=connectionId,proto3" json:"connection_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WSCloseConfig) Reset() { + *x = WSCloseConfig{} + mi := &file_websocket_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WSCloseConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WSCloseConfig) ProtoMessage() {} + +func (x *WSCloseConfig) ProtoReflect() protoreflect.Message { + mi := &file_websocket_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WSCloseConfig.ProtoReflect.Descriptor instead. +func (*WSCloseConfig) Descriptor() ([]byte, []int) { + return file_websocket_proto_rawDescGZIP(), []int{4} +} + +func (x *WSCloseConfig) GetConnectionId() string { + if x != nil { + return x.ConnectionId + } + return "" +} + +// WSCloseInput carries optional runtime overrides for step.ws_close. +type WSCloseInput struct { + state protoimpl.MessageState `protogen:"open.v1"` + Data *structpb.Struct `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WSCloseInput) Reset() { + *x = WSCloseInput{} + mi := &file_websocket_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WSCloseInput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WSCloseInput) ProtoMessage() {} + +func (x *WSCloseInput) ProtoReflect() protoreflect.Message { + mi := &file_websocket_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WSCloseInput.ProtoReflect.Descriptor instead. +func (*WSCloseInput) Descriptor() ([]byte, []int) { + return file_websocket_proto_rawDescGZIP(), []int{5} +} + +func (x *WSCloseInput) GetData() *structpb.Struct { + if x != nil { + return x.Data + } + return nil +} + +// WSCloseOutput is the result of step.ws_close. +type WSCloseOutput struct { + state protoimpl.MessageState `protogen:"open.v1"` + // closed is true when the connection was found and closed. + Closed bool `protobuf:"varint,1,opt,name=closed,proto3" json:"closed,omitempty"` + // error is populated when the close failed (ws.server not initialized). + Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WSCloseOutput) Reset() { + *x = WSCloseOutput{} + mi := &file_websocket_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WSCloseOutput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WSCloseOutput) ProtoMessage() {} + +func (x *WSCloseOutput) ProtoReflect() protoreflect.Message { + mi := &file_websocket_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WSCloseOutput.ProtoReflect.Descriptor instead. +func (*WSCloseOutput) Descriptor() ([]byte, []int) { + return file_websocket_proto_rawDescGZIP(), []int{6} +} + +func (x *WSCloseOutput) GetClosed() bool { + if x != nil { + return x.Closed + } + return false +} + +func (x *WSCloseOutput) GetError() string { + if x != nil { + return x.Error + } + return "" +} + +// WebSocketTriggerConfig is the typed config for the websocket trigger type. +// No required fields — the trigger attaches to the global ws.server hub automatically. +type WebSocketTriggerConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WebSocketTriggerConfig) Reset() { + *x = WebSocketTriggerConfig{} + mi := &file_websocket_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WebSocketTriggerConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WebSocketTriggerConfig) ProtoMessage() {} + +func (x *WebSocketTriggerConfig) ProtoReflect() protoreflect.Message { + mi := &file_websocket_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WebSocketTriggerConfig.ProtoReflect.Descriptor instead. +func (*WebSocketTriggerConfig) Descriptor() ([]byte, []int) { + return file_websocket_proto_rawDescGZIP(), []int{7} +} + +var File_websocket_proto protoreflect.FileDescriptor + +const file_websocket_proto_rawDesc = "" + + "\n" + + "\x0fwebsocket.proto\x12\x1cworkflow.plugin.websocket.v1\x1a\x1cgoogle/protobuf/struct.proto\"\xde\x01\n" + + "\x0eWSServerConfig\x12\x12\n" + + "\x04path\x18\x01 \x01(\tR\x04path\x12'\n" + + "\x0fmax_connections\x18\x02 \x01(\x05R\x0emaxConnections\x12#\n" + + "\rping_interval\x18\x03 \x01(\tR\fpingInterval\x12\x1b\n" + + "\tpong_wait\x18\x04 \x01(\tR\bpongWait\x12(\n" + + "\x10max_message_size\x18\x05 \x01(\x03R\x0emaxMessageSize\x12#\n" + + "\rauth_required\x18\x06 \x01(\bR\fauthRequired\"M\n" + + "\fWSSendConfig\x12#\n" + + "\rconnection_id\x18\x01 \x01(\tR\fconnectionId\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\":\n" + + "\vWSSendInput\x12+\n" + + "\x04data\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x04data\"8\n" + + "\fWSSendOutput\x12\x12\n" + + "\x04sent\x18\x01 \x01(\bR\x04sent\x12\x14\n" + + "\x05error\x18\x02 \x01(\tR\x05error\"4\n" + + "\rWSCloseConfig\x12#\n" + + "\rconnection_id\x18\x01 \x01(\tR\fconnectionId\";\n" + + "\fWSCloseInput\x12+\n" + + "\x04data\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x04data\"=\n" + + "\rWSCloseOutput\x12\x16\n" + + "\x06closed\x18\x01 \x01(\bR\x06closed\x12\x14\n" + + "\x05error\x18\x02 \x01(\tR\x05error\"\x18\n" + + "\x16WebSocketTriggerConfigBBZ@github.com/GoCodeAlone/workflow-plugin-websocket/gen;websocketv1b\x06proto3" + +var ( + file_websocket_proto_rawDescOnce sync.Once + file_websocket_proto_rawDescData []byte +) + +func file_websocket_proto_rawDescGZIP() []byte { + file_websocket_proto_rawDescOnce.Do(func() { + file_websocket_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_websocket_proto_rawDesc), len(file_websocket_proto_rawDesc))) + }) + return file_websocket_proto_rawDescData +} + +var file_websocket_proto_msgTypes = make([]protoimpl.MessageInfo, 8) +var file_websocket_proto_goTypes = []any{ + (*WSServerConfig)(nil), // 0: workflow.plugin.websocket.v1.WSServerConfig + (*WSSendConfig)(nil), // 1: workflow.plugin.websocket.v1.WSSendConfig + (*WSSendInput)(nil), // 2: workflow.plugin.websocket.v1.WSSendInput + (*WSSendOutput)(nil), // 3: workflow.plugin.websocket.v1.WSSendOutput + (*WSCloseConfig)(nil), // 4: workflow.plugin.websocket.v1.WSCloseConfig + (*WSCloseInput)(nil), // 5: workflow.plugin.websocket.v1.WSCloseInput + (*WSCloseOutput)(nil), // 6: workflow.plugin.websocket.v1.WSCloseOutput + (*WebSocketTriggerConfig)(nil), // 7: workflow.plugin.websocket.v1.WebSocketTriggerConfig + (*structpb.Struct)(nil), // 8: google.protobuf.Struct +} +var file_websocket_proto_depIdxs = []int32{ + 8, // 0: workflow.plugin.websocket.v1.WSSendInput.data:type_name -> google.protobuf.Struct + 8, // 1: workflow.plugin.websocket.v1.WSCloseInput.data:type_name -> google.protobuf.Struct + 2, // [2:2] is the sub-list for method output_type + 2, // [2:2] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_websocket_proto_init() } +func file_websocket_proto_init() { + if File_websocket_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_websocket_proto_rawDesc), len(file_websocket_proto_rawDesc)), + NumEnums: 0, + NumMessages: 8, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_websocket_proto_goTypes, + DependencyIndexes: file_websocket_proto_depIdxs, + MessageInfos: file_websocket_proto_msgTypes, + }.Build() + File_websocket_proto = out.File + file_websocket_proto_goTypes = nil + file_websocket_proto_depIdxs = nil +} diff --git a/internal/contracts.go b/internal/contracts.go new file mode 100644 index 0000000..b2e1f9e --- /dev/null +++ b/internal/contracts.go @@ -0,0 +1,67 @@ +package internal + +import ( + websocketv1 "github.com/GoCodeAlone/workflow-plugin-websocket/gen" + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" + "google.golang.org/protobuf/reflect/protodesc" + "google.golang.org/protobuf/types/descriptorpb" + "google.golang.org/protobuf/types/known/structpb" +) + +// ContractRegistry returns the typed contract descriptors for all websocket +// module, step, and trigger types. The workflow engine calls this via the +// sdk.ContractProvider interface for strict validation. +func (p *wsPlugin) ContractRegistry() *pb.ContractRegistry { + return websocketContractRegistry +} + +// wsProtoPkg is the proto package prefix for all websocket typed messages. +const wsProtoPkg = "workflow.plugin.websocket.v1." + +// websocketContractRegistry declares STRICT_PROTO contracts for the ws.server +// module, step.ws_send, step.ws_close, and the websocket trigger type. +var websocketContractRegistry = &pb.ContractRegistry{ + FileDescriptorSet: &descriptorpb.FileDescriptorSet{ + File: []*descriptorpb.FileDescriptorProto{ + protodesc.ToFileDescriptorProto(structpb.File_google_protobuf_struct_proto), + protodesc.ToFileDescriptorProto(websocketv1.File_websocket_proto), + }, + }, + Contracts: []*pb.ContractDescriptor{ + // ── module ─────────────────────────────────────────────────────────────── + { + Kind: pb.ContractKind_CONTRACT_KIND_MODULE, + ModuleType: "ws.server", + ConfigMessage: wsProtoPkg + "WSServerConfig", + Mode: pb.ContractMode_CONTRACT_MODE_STRICT_PROTO, + }, + // ── step.ws_send ───────────────────────────────────────────────────────── + { + Kind: pb.ContractKind_CONTRACT_KIND_STEP, + StepType: "step.ws_send", + ConfigMessage: wsProtoPkg + "WSSendConfig", + InputMessage: wsProtoPkg + "WSSendInput", + OutputMessage: wsProtoPkg + "WSSendOutput", + Mode: pb.ContractMode_CONTRACT_MODE_STRICT_PROTO, + }, + // ── step.ws_close ──────────────────────────────────────────────────────── + { + Kind: pb.ContractKind_CONTRACT_KIND_STEP, + StepType: "step.ws_close", + ConfigMessage: wsProtoPkg + "WSCloseConfig", + InputMessage: wsProtoPkg + "WSCloseInput", + OutputMessage: wsProtoPkg + "WSCloseOutput", + Mode: pb.ContractMode_CONTRACT_MODE_STRICT_PROTO, + }, + // ── trigger: websocket ─────────────────────────────────────────────────── + { + Kind: pb.ContractKind_CONTRACT_KIND_TRIGGER, + TriggerType: "websocket", + ConfigMessage: wsProtoPkg + "WebSocketTriggerConfig", + Mode: pb.ContractMode_CONTRACT_MODE_STRICT_PROTO, + }, + }, +} + +// Compile-time assertion: wsPlugin implements sdk.ContractProvider. +var _ interface{ ContractRegistry() *pb.ContractRegistry } = (*wsPlugin)(nil) diff --git a/internal/contracts_test.go b/internal/contracts_test.go new file mode 100644 index 0000000..4b33ef1 --- /dev/null +++ b/internal/contracts_test.go @@ -0,0 +1,80 @@ +package internal + +import ( + "testing" + + pb "github.com/GoCodeAlone/workflow/plugin/external/proto" +) + +func TestContractRegistry_HasAllSurfaces(t *testing.T) { + p := &wsPlugin{} + reg := p.ContractRegistry() + + if reg == nil { + t.Fatal("ContractRegistry() returned nil") + } + + // Expect 4 contracts: 1 module + 2 steps + 1 trigger + if len(reg.Contracts) != 4 { + t.Fatalf("expected 4 contracts, got %d", len(reg.Contracts)) + } + + type want struct { + kind pb.ContractKind + moduleType string + stepType string + triggerType string + } + wantContracts := []want{ + {kind: pb.ContractKind_CONTRACT_KIND_MODULE, moduleType: "ws.server"}, + {kind: pb.ContractKind_CONTRACT_KIND_STEP, stepType: "step.ws_send"}, + {kind: pb.ContractKind_CONTRACT_KIND_STEP, stepType: "step.ws_close"}, + {kind: pb.ContractKind_CONTRACT_KIND_TRIGGER, triggerType: "websocket"}, + } + + for i, w := range wantContracts { + c := reg.Contracts[i] + if c.Kind != w.kind { + t.Errorf("contract[%d]: want kind %v, got %v", i, w.kind, c.Kind) + } + if c.Mode != pb.ContractMode_CONTRACT_MODE_STRICT_PROTO { + t.Errorf("contract[%d]: want STRICT_PROTO mode, got %v", i, c.Mode) + } + if w.moduleType != "" && c.ModuleType != w.moduleType { + t.Errorf("contract[%d]: want module_type %q, got %q", i, w.moduleType, c.ModuleType) + } + if w.stepType != "" && c.StepType != w.stepType { + t.Errorf("contract[%d]: want step_type %q, got %q", i, w.stepType, c.StepType) + } + if w.triggerType != "" && c.TriggerType != w.triggerType { + t.Errorf("contract[%d]: want trigger_type %q, got %q", i, w.triggerType, c.TriggerType) + } + } +} + +func TestContractRegistry_FileDescriptorSetIncludesWebsocketAndStruct(t *testing.T) { + p := &wsPlugin{} + reg := p.ContractRegistry() + + if reg.FileDescriptorSet == nil { + t.Fatal("FileDescriptorSet is nil") + } + + var foundWebsocket, foundStruct bool + for _, fd := range reg.FileDescriptorSet.File { + name := fd.GetName() + if name == "websocket.proto" { + foundWebsocket = true + } + if name == "google/protobuf/struct.proto" { + foundStruct = true + } + } + + if !foundWebsocket { + t.Error("FileDescriptorSet missing websocket.proto") + } + if !foundStruct { + t.Error("FileDescriptorSet missing google/protobuf/struct.proto") + } +} diff --git a/plugin.contracts.json b/plugin.contracts.json new file mode 100644 index 0000000..727ff29 --- /dev/null +++ b/plugin.contracts.json @@ -0,0 +1,33 @@ +{ + "version": "1", + "contracts": [ + { + "kind": "module", + "type": "ws.server", + "mode": "strict_proto", + "config": "workflow.plugin.websocket.v1.WSServerConfig" + }, + { + "kind": "step", + "type": "step.ws_send", + "mode": "strict_proto", + "config": "workflow.plugin.websocket.v1.WSSendConfig", + "input": "workflow.plugin.websocket.v1.WSSendInput", + "output": "workflow.plugin.websocket.v1.WSSendOutput" + }, + { + "kind": "step", + "type": "step.ws_close", + "mode": "strict_proto", + "config": "workflow.plugin.websocket.v1.WSCloseConfig", + "input": "workflow.plugin.websocket.v1.WSCloseInput", + "output": "workflow.plugin.websocket.v1.WSCloseOutput" + }, + { + "kind": "trigger", + "type": "websocket", + "mode": "strict_proto", + "config": "workflow.plugin.websocket.v1.WebSocketTriggerConfig" + } + ] +} diff --git a/proto/websocket/v1/websocket.proto b/proto/websocket/v1/websocket.proto new file mode 100644 index 0000000..66ea121 --- /dev/null +++ b/proto/websocket/v1/websocket.proto @@ -0,0 +1,70 @@ +syntax = "proto3"; + +package workflow.plugin.websocket.v1; + +import "google/protobuf/struct.proto"; + +option go_package = "github.com/GoCodeAlone/workflow-plugin-websocket/gen;websocketv1"; + +// WSServerConfig is the typed config for the ws.server module. +// Fields mirror the internal wsServerModule config keys (camelCase in YAML). +message WSServerConfig { + // path is the HTTP path for the WebSocket upgrade endpoint (default: /ws). + string path = 1; + // max_connections is the maximum number of concurrent WebSocket connections (default: 1000). + int32 max_connections = 2; + // ping_interval is the duration between server-initiated pings (e.g. "30s"). Default: 30s. + string ping_interval = 3; + // pong_wait is the time to wait for a pong reply before disconnecting (e.g. "60s"). + string pong_wait = 4; + // max_message_size is the maximum inbound message size in bytes (default: 65536). + int64 max_message_size = 5; + // auth_required, when true, requires an authenticated session before accepting connections. + bool auth_required = 6; +} + +// WSSendConfig is the typed config for step.ws_send. +message WSSendConfig { + // connection_id is the UUID of the WebSocket connection to send the message to. + string connection_id = 1; + // message is the text payload to deliver. + string message = 2; +} + +// WSSendInput carries optional runtime overrides for step.ws_send. +message WSSendInput { + google.protobuf.Struct data = 1; +} + +// WSSendOutput is the result of step.ws_send. +message WSSendOutput { + // sent is true when the message was delivered to the connection. + bool sent = 1; + // error is populated when the send failed (ws.server not initialized, missing connectionId). + string error = 2; +} + +// WSCloseConfig is the typed config for step.ws_close. +message WSCloseConfig { + // connection_id is the UUID of the WebSocket connection to close. + string connection_id = 1; +} + +// WSCloseInput carries optional runtime overrides for step.ws_close. +message WSCloseInput { + google.protobuf.Struct data = 1; +} + +// WSCloseOutput is the result of step.ws_close. +message WSCloseOutput { + // closed is true when the connection was found and closed. + bool closed = 1; + // error is populated when the close failed (ws.server not initialized). + string error = 2; +} + +// WebSocketTriggerConfig is the typed config for the websocket trigger type. +// No required fields — the trigger attaches to the global ws.server hub automatically. +message WebSocketTriggerConfig { + // reserved for future extension +}