Skip to content
Technical preview. This site is published for review. Everything on it, including the API, tokens and module protocol, is subject to change.
Write a module

Write a module

A workflow can only affect the world through actions, and actions come from modules. A module is a program that describes its variables, actions and files in a descriptor and executes action invocations. The contract between a module and AutoFlow is a gRPC and protobuf protocol, ModuleAPI and ModuleTunnelAPI in pkg/autoflow/rpc/rpc.proto, so a module can be written in any language. The Go SDK in pkg/autoflow is one implementation of that protocol, and the worked example below uses it.

The protocol

A module does three things.

  1. Register a descriptor. It calls ModuleAPI/RegisterDescriptor with a ModuleDescriptor: the proto descriptor set for the message types its values use, its variables, its actions and its Starlark files. A registration expires after 30 minutes, so a module re-registers periodically; DeregisterDescriptor removes it at once. Names of built-in modules are reserved, and a descriptor registered over the wire may not mark an action inlineable.
  2. Answer invocations over the tunnel. It opens ModuleTunnelAPI/InvokeAction, a reverse stream: the module connects and waits, and AutoFlow sends an ActionInvocation for each call a workflow makes. The invocation names the action and the workflow and carries the call as the workflow made it, positional args and keyword kwargs as Values, plus one send-only token per channel among the arguments, the workflow’s annotations and its namespace. The module answers one Result: a Value on success, or an Error with a message and a retryable flag. Binding the arguments to typed parameters is the module’s job.
  3. Deduplicate on the idempotency key. ActionInvocation.idempotency_key identifies a logical invocation and is identical on every execution of it: a retry after a failure, a re-run after a crash between the module’s answer and its recording, or the escalation of an inline attempt to a queued one. A module MUST deduplicate on it and MUST answer a duplicate with the result the first execution produced. An action with no side effect satisfies this by construction; one that writes somewhere carries the key to the destination or stores the first result under it.

A module that sends values into a workflow’s channels calls ModuleAPI/SendToChannel, streaming batches of 1 to 60 values, each with its own idempotency key, to the channel a token from the invocation addresses.

message ModuleDescriptor {
  google.protobuf.FileDescriptorSet descriptor_set = 1;
  repeated Var vars = 2;
  repeated Action actions = 3;
  repeated File files = 4;
}

message Result {
  oneof result {
    Value value = 1;
    Error error = 2;
  }
}

The design document on modules covers the wire protocol, the value mapping and the file mechanism in full.

With the Go SDK

The SDK is the MIT-licensed Go module gitlab.com/gitlab-org/cluster-integration/gitlab-agent/pkg/autoflow/v19 in the GitLab Relay (formerly KAS) repository. It reduces the protocol to one interface:

type Module interface {
    Name() string
    LoadModuleDescriptor(ctx context.Context) (*ModuleDescriptor, error)
    InvokeAction(ctx context.Context, inv *ActionInvocation) (*Result, error)
}

NewModule builds a value that implements it, so you rarely implement the interface by hand. The one part of the contract that stays yours is the deduplication on inv.IdempotencyKey.

Parameters as a proto message

Each action’s parameters are a proto message. Keyword arguments bind to the field of the same name; positional arguments fill the fields you list with WithPositionalParams. The message must use explicit field presence so that required can tell “not supplied” from “supplied as the zero value”, and buf.validate rules run after binding.

edition = "2023";

package example.greeting;

import "buf/validate/validate.proto";

option features.field_presence = EXPLICIT;
option go_package = "example.com/greeting";

message GreetParams {
  string name = 1 [
    (buf.validate.field).required = true,
    (buf.validate.field).string.min_bytes = 1
  ];
  string language = 2 [(buf.validate.field).string.in = ["en", "de"]];
}

A binding or validation failure comes back to the workflow as an error result with the message, because the workflow passed something wrong and a retry cannot help.

The module

package greeting

import (
	"context"

	"buf.build/go/protovalidate"
	"gitlab.com/gitlab-org/cluster-integration/gitlab-agent/pkg/autoflow/v19"
)

const (
	ModuleName         = "greeting"
	greetActionName    = "greet"
	defaultLanguageVar = "DEFAULT_LANGUAGE"
)

type module struct{}

// New returns the greeting module:
//
//	load("module:greeting", "greet", "DEFAULT_LANGUAGE")
//
//	def main(w):
//	    print(gather(greet("Ada", language = DEFAULT_LANGUAGE)))
func New(validator protovalidate.Validator) *autoflow.ModuleImpl {
	m := &module{}
	return autoflow.NewModule(ModuleName, validator,
		autoflow.WithVars(
			autoflow.NewVar(defaultLanguageVar, autoflow.String("en")),
		),
		autoflow.WithAction(greetActionName, m.greet,
			autoflow.WithPositionalParams(1, "name"),
			autoflow.WithInlineable(),
		),
	)
}

func (m *module) greet(
	ctx context.Context,
	inv *autoflow.ActionInvocation,
	p *GreetParams,
) (*autoflow.Result, error) {
	greeting := "Hello"
	if p.Language != nil && *p.Language == "de" {
		greeting = "Hallo"
	}
	return autoflow.ResultOk(autoflow.String(greeting + ", " + *p.Name)), nil
}

What each piece does:

  • NewModule(name, validator, opts...) assembles the descriptor and validates it. The name must match ^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$, at most 255 bytes, and by convention is singular. validator is a protovalidate.Validator, from protovalidate.New().
  • WithAction[P](name, fn, opts...) registers an action whose parameters are P. fn has the type ActionFunc[P]: func(ctx context.Context, inv *ActionInvocation, params P) (*Result, error). Explicit presence makes scalar fields pointers, hence *p.Name.
  • WithPositionalParams(1, "name") makes name positional-only and required by position; language stays keyword-only. So greet("Ada", language = "de") binds, greet(name = "Ada") does not.
  • WithInlineable() lets AutoFlow attempt the action in the calling workflow’s goroutine instead of queueing a task, and queue it if the attempt fails. Only for a body that is quick, in-process and idempotent; the attempt timeout is capped at 500 ms. A failed inline attempt may already have had an effect before the queued execution repeats it, which is one more reason the destination has to deduplicate on inv.IdempotencyKey.
  • WithVars exposes frozen values a workflow definition imports by name. WithFiles(&autoflow.File{Name: "helpers.star", Data: src}) ships Starlark a definition loads with load("module:greeting?file=helpers.star", ...). WithFileDescriptorProto declares proto types your values embed.
  • WithRawAction hands the body the call as the workflow made it when no message can describe the arguments. Prefer WithAction.

Results and errors

Return one of three shapes, built with the helpers in the SDK:

  • ResultOk(value): success. Build the value with String, Integer, Bool, Bytes, Float, Timestamp, Duration, None, List, Tuple, Set, Dict with KV pairs, Struct, SecretString, SecretBytes or Message. Prefer a dict over a tuple for results that may grow.
  • ResultErr(msg): the action ran and failed, and re-running cannot help, for example the arguments were valid but the system behind the module rejected them. The workflow fails when it gathers the future. Never retried.
  • ResultRetryableErr(msg): a transient failure. Only poll cares: it counts as “not ready” and polls again. Outside poll it fails the workflow like any error.
  • A Go error: the invocation itself could not be completed, and autocore retries it with exponential backoff, by default 20 attempts over about an hour and three quarters, every attempt under the same idempotency key, and the workflow sees only the final outcome. Use it for a blip in your own dependency, as the event module does when publishing fails, and never for something the workflow definition got wrong.

Error messages are capped at 4 KiB and are visible to whoever inspects the workflow, so keep secrets out of them.

greet has no side effect, so a duplicate execution produces the same result by construction. An action that writes somewhere has to carry inv.IdempotencyKey to the destination, as the gitlab module does with the Idempotency-Key header, or store the first result under the key and return it on a repeat.

Channels and annotations

inv.ChannelTokens holds one send-only token per channel the workflow passed in the arguments. A module sends values with the SendToChannel RPC, in batches of 1 to 60 values each carrying its own idempotency key, during or after the action. inv.Annotations carries the workflow’s annotations and inv.ExternalNamespaceId its namespace, for routing or audit.

How modules reach workflows today

Today, modules are compiled into Relay, the server that hosts AutoFlow. The four built-ins, gitlab, policy, event and gitlab-function, are Go modules built with the SDK exactly like the one above, registered in internal/module/autoflow/builtin_modules with a fixed id each and served in-process. A new capability ships the same way: a Go package built against the SDK, wired into that registry, released with Relay.

Roadmap: remote registration

AutoFlow serves the protocol above, and a remote module would connect as an agent of type autoflow_module, register its descriptor and wait on the tunnel for invocations. What does not exist yet is a way for a third party to obtain credentials for such an agent, so treat remote modules as roadmap and build against the SDK now: the same Module value serves either path.

Last updated on