Skip to content
Draft
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
27 changes: 25 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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 ######
Expand All @@ -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"
Expand All @@ -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)

Expand Down Expand Up @@ -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..."
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
179 changes: 179 additions & 0 deletions cmd/protoc-gen-system-nexus-wit/generator.go
Original file line number Diff line number Diff line change
@@ -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)
}
8 changes: 8 additions & 0 deletions cmd/protoc-gen-system-nexus-wit/go.mod
Original file line number Diff line number Diff line change
@@ -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
)
8 changes: 8 additions & 0 deletions cmd/protoc-gen-system-nexus-wit/go.sum
Original file line number Diff line number Diff line change
@@ -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=
11 changes: 11 additions & 0 deletions cmd/protoc-gen-system-nexus-wit/main.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
78 changes: 78 additions & 0 deletions nexus/temporal-system.wit
Original file line number Diff line number Diff line change
@@ -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<duration>,
workflow-run-timeout: option<duration>,
workflow-task-timeout: option<duration>,
identity: option<string>,
request-id: option<string>,
workflow-id-reuse-policy: option<workflow-id-reuse-policy>,
workflow-id-conflict-policy: option<workflow-id-conflict-policy>,
retry-policy: option<retry-policy>,
cron-schedule: option<string>,
memo: option<memo>,
search-attributes: option<search-attributes>,
priority: option<priority>,
versioning-override: option<versioning-override>,
workflow-start-delay: option<duration>,
user-metadata: option<user-metadata>,
/// @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<string>,
started: option<bool>,
/// @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;
}
Loading