diff --git a/Makefile b/Makefile index ea42652e9..8d230e741 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ ci-build: install proto http-api-docs install: grpc-install api-linter-install buf-install # Run all linters and compile proto files. -proto: grpc http-api-docs nexus-rpc-yaml +proto: grpc http-api-docs nexus-rpc-yaml system-nexus-wit ######################################################################## ##### Variables ###### @@ -17,7 +17,9 @@ GOPATH := $(shell go env GOPATH) endif GOBIN := $(if $(shell go env GOBIN),$(shell go env GOBIN),$(GOPATH)/bin) -PATH := $(GOBIN):$(PATH) +CARGO_HOME ?= $(HOME)/.cargo +CARGO_BIN := $(CARGO_HOME)/bin +PATH := $(GOBIN):$(CARGO_BIN):$(PATH) STAMPDIR := .stamp COLOR := "\e[1;36m%s\e[0m\n" @@ -33,6 +35,10 @@ PROTO_PATHS = paths=source_relative:$(PROTO_OUT) OAPI_OUT := openapi OAPI3_PATH := .components.schemas.Payload +SYSTEM_NEXUS_WIT := nexus/temporal-system.wit +SYSTEM_NEXUS_SERVICE_PROTO_FILES := $(shell find temporal/api -name "service.proto" | sort) +NEXUS_API_GEN ?= nexus-api-gen + $(PROTO_OUT): mkdir $(PROTO_OUT) @@ -137,6 +143,23 @@ nexus-rpc-yaml-install: printf $(COLOR) "Build and install protoc-gen-nexus-rpc-yaml..." @cd cmd/protoc-gen-nexus-rpc-yaml && go install . +##### Compile system Nexus WIT files ##### +system-nexus-wit: system-nexus-wit-install nexus-api-gen-install + printf $(COLOR) "Generate system Nexus WIT..." + protoc -I $(PROTO_ROOT) \ + --system-nexus-wit_opt=output=$(SYSTEM_NEXUS_WIT) \ + --system-nexus-wit_opt=nexus_api_gen=$(NEXUS_API_GEN) \ + --system-nexus-wit_out=. \ + $(SYSTEM_NEXUS_SERVICE_PROTO_FILES) + +system-nexus-wit-install: + printf $(COLOR) "Build and install protoc-gen-system-nexus-wit..." + @cd cmd/protoc-gen-system-nexus-wit && go install . + +nexus-api-gen-install: + printf $(COLOR) "Install nexus-api-gen if missing..." + command -v $(NEXUS_API_GEN) >/dev/null || CARGO_NET_GIT_FETCH_WITH_CLI=true cargo install --git https://github.com/temporalio/nexus-api-gen + ##### Clean ##### clean: printf $(COLOR) "Delete generated go files..." diff --git a/README.md b/README.md index 788613ae8..b31d2f70d 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Install as git submodule to the project. ## Contribution Make your change to the temporal/proto files, and run `make` to update the openapi definitions. +Rust is also required because `make` installs and runs `nexus-api-gen` when regenerating system Nexus WIT files. ## Breaking changes diff --git a/cmd/protoc-gen-system-nexus-wit/generator.go b/cmd/protoc-gen-system-nexus-wit/generator.go new file mode 100644 index 000000000..31fdfbb43 --- /dev/null +++ b/cmd/protoc-gen-system-nexus-wit/generator.go @@ -0,0 +1,179 @@ +package main + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + + nexusannotationsv1 "github.com/nexus-rpc/nexus-proto-annotations/go/nexusannotations/v1" + "google.golang.org/protobuf/compiler/protogen" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/descriptorpb" +) + +type params struct { + nexusAPIGen string + output string + input string +} + +// parseParams parses the comma-separated key=value parameter string provided by protoc. +// +// - output: required. Path to the WIT file to generate, relative to the +// --system-nexus-wit_out directory. Example: "nexus/temporal-system.wit". +// +// - nexus_api_gen: optional. Path to the nexus-api-gen binary. Defaults to +// "nexus-api-gen". +// +// - input: optional. Existing WIT file to update. Defaults to output, so +// existing handwritten annotations and type refinements are preserved when +// regenerating in place. +func parseParams(raw string) (params, error) { + p := params{ + nexusAPIGen: "nexus-api-gen", + } + if raw == "" { + return p, nil + } + for kv := range strings.SplitSeq(raw, ",") { + key, value, ok := strings.Cut(kv, "=") + if !ok { + return p, fmt.Errorf("invalid parameter %q: expected key=value", kv) + } + switch key { + case "nexus_api_gen": + p.nexusAPIGen = value + case "output": + p.output = value + case "input": + p.input = value + default: + return p, fmt.Errorf("unknown parameter %q", key) + } + } + return p, nil +} + +func generate(gen *protogen.Plugin) error { + p, err := parseParams(gen.Request.GetParameter()) + if err != nil { + return err + } + if p.output == "" { + return fmt.Errorf("missing required output parameter") + } + if p.input == "" { + p.input = p.output + } + + rpcs := exposedRPCs(gen) + if len(rpcs) == 0 { + return fmt.Errorf("no proto RPCs are marked as exposed Nexus operations") + } + + tempDir, err := os.MkdirTemp("", "system-nexus-wit-*") + if err != nil { + return err + } + defer os.RemoveAll(tempDir) + + descriptorPath := filepath.Join(tempDir, "temporal_api.bin") + if err := writeDescriptorSet(gen, descriptorPath); err != nil { + return err + } + + tempOutput := filepath.Join(tempDir, "system-nexus.wit") + input := "" + if _, err := os.Stat(p.input); err == nil { + if err := copyFile(p.input, tempOutput); err != nil { + return err + } + input = tempOutput + } else if !os.IsNotExist(err) { + return err + } + + for _, rpc := range rpcs { + if err := runAddRPC(p.nexusAPIGen, descriptorPath, rpc, tempOutput, input); err != nil { + return err + } + input = tempOutput + } + + content, err := os.ReadFile(tempOutput) + if err != nil { + return err + } + _, err = gen.NewGeneratedFile(p.output, "").Write(content) + return err +} + +func exposedRPCs(gen *protogen.Plugin) []string { + var rpcs []string + for _, f := range gen.Files { + if !f.Generate { + continue + } + for _, svc := range f.Services { + for _, m := range svc.Methods { + if isExposedOperation(m) { + rpcs = append(rpcs, string(m.Desc.FullName())) + } + } + } + } + return rpcs +} + +func isExposedOperation(m *protogen.Method) bool { + opts, ok := m.Desc.Options().(*descriptorpb.MethodOptions) + if !ok || opts == nil { + return false + } + if !proto.HasExtension(opts, nexusannotationsv1.E_Operation) { + return false + } + tags := proto.GetExtension(opts, nexusannotationsv1.E_Operation).(*nexusannotationsv1.OperationOptions).GetTags() + return slices.Contains(tags, "exposed") +} + +func writeDescriptorSet(gen *protogen.Plugin, descriptorPath string) error { + data, err := proto.Marshal(&descriptorpb.FileDescriptorSet{ + File: gen.Request.GetProtoFile(), + }) + if err != nil { + return err + } + return os.WriteFile(descriptorPath, data, 0o644) +} + +func runAddRPC(nexusAPIGen string, descriptors string, rpc string, output string, input string) error { + args := []string{ + "add-rpc", + "--descriptors", descriptors, + "--rpc", rpc, + "--output", output, + } + if input != "" { + args = append(args, "--input", input) + } + + command := exec.Command(nexusAPIGen, args...) + command.Stdout = os.Stdout + command.Stderr = os.Stderr + if err := command.Run(); err != nil { + return fmt.Errorf("%s %s: %w", nexusAPIGen, strings.Join(args, " "), err) + } + return nil +} + +func copyFile(source string, destination string) error { + content, err := os.ReadFile(source) + if err != nil { + return err + } + return os.WriteFile(destination, content, 0o644) +} diff --git a/cmd/protoc-gen-system-nexus-wit/go.mod b/cmd/protoc-gen-system-nexus-wit/go.mod new file mode 100644 index 000000000..222e32187 --- /dev/null +++ b/cmd/protoc-gen-system-nexus-wit/go.mod @@ -0,0 +1,8 @@ +module github.com/temporalio/api/cmd/protoc-gen-system-nexus-wit + +go 1.25.4 + +require ( + github.com/nexus-rpc/nexus-proto-annotations v0.0.0-20260330194009-e558d6edaf84 + google.golang.org/protobuf v1.36.1 +) diff --git a/cmd/protoc-gen-system-nexus-wit/go.sum b/cmd/protoc-gen-system-nexus-wit/go.sum new file mode 100644 index 000000000..c96f6a1c5 --- /dev/null +++ b/cmd/protoc-gen-system-nexus-wit/go.sum @@ -0,0 +1,8 @@ +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/nexus-rpc/nexus-proto-annotations v0.0.0-20260330194009-e558d6edaf84 h1:SWHt3Coj0VvF0Km1A0wlY+IjnHKsjQLgO29io84r3wY= +github.com/nexus-rpc/nexus-proto-annotations v0.0.0-20260330194009-e558d6edaf84/go.mod h1:n3UjF1bPCW8llR8tHvbxJ+27yPWrhpo8w/Yg1IOuY0Y= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= +google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= diff --git a/cmd/protoc-gen-system-nexus-wit/main.go b/cmd/protoc-gen-system-nexus-wit/main.go new file mode 100644 index 000000000..0e9fe3182 --- /dev/null +++ b/cmd/protoc-gen-system-nexus-wit/main.go @@ -0,0 +1,11 @@ +// protoc-gen-system-nexus-wit generates nexus/temporal-system.wit from proto service +// methods annotated with option (nexusannotations.v1.operation).tags = "exposed". +package main + +import "google.golang.org/protobuf/compiler/protogen" + +func main() { + protogen.Options{}.Run(func(gen *protogen.Plugin) error { + return generate(gen) + }) +} diff --git a/nexus/temporal-system.wit b/nexus/temporal-system.wit new file mode 100644 index 000000000..e410c64d7 --- /dev/null +++ b/nexus/temporal-system.wit @@ -0,0 +1,78 @@ +package temporal:nexus@1.0.0; + +world system { + export workflow-service; +} + +/// @nexus.endpoint "__temporal_system" +interface workflow-service { + use nexus:temporal-types/model@1.0.0.{ + duration, + memo, + payloads, + placeholder, + priority, + retry-policy, + search-attributes, + signal-function, + task-queue, + user-metadata, + versioning-override, + workflow-function, + workflow-id-conflict-policy, + workflow-id-reuse-policy, + }; + + /// @nexus.proto "temporal.api.workflowservice.v1.SignalWithStartWorkflowExecutionRequest" + record signal-with-start-workflow-request { + /// @nexus.proto-field "workflow_type" + workflow: workflow-function, + workflow-id: string, + task-queue: task-queue, + /// @nexus.proto-field "signal_name" + signal: signal-function, + workflow-execution-timeout: option, + workflow-run-timeout: option, + workflow-task-timeout: option, + identity: option, + request-id: option, + workflow-id-reuse-policy: option, + workflow-id-conflict-policy: option, + retry-policy: option, + cron-schedule: option, + memo: option, + search-attributes: option, + priority: option, + versioning-override: option, + workflow-start-delay: option, + user-metadata: option, + /// @nexus.source python="workflow_namespace" typescript="workflowNamespace" + namespace: string, + /// @nexus.omit + control: placeholder, + /// @nexus.omit + header: placeholder, + /// @nexus.omit + links: placeholder, + /// @nexus.omit + time-skipping-config: placeholder, + } + + /// @nexus.proto "temporal.api.workflowservice.v1.SignalWithStartWorkflowExecutionResponse" + record signal-with-start-workflow-response { + run-id: option, + started: option, + /// @nexus.omit + signal-link: placeholder, + } + + /// @nexus.output-transform + /// python-type="workflow.ExternalWorkflowHandle[typing.Any]" + /// python="workflow.get_external_workflow_handle(request.workflow_id, run_id=result.run_id)" + /// typescript-type="workflow.ExternalWorkflowHandle" + /// typescript="workflow.getExternalWorkflowHandle(request.workflowId, result.runId ?? undefined)" + /// @nexus.operation name="SignalWithStartWorkflowExecution" + signal-with-start-workflow: func( + request: signal-with-start-workflow-request, + ) -> signal-with-start-workflow-response; +}