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.
- Register a descriptor. It calls
ModuleAPI/RegisterDescriptorwith aModuleDescriptor: 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;DeregisterDescriptorremoves it at once. Names of built-in modules are reserved, and a descriptor registered over the wire may not mark an action inlineable. - Answer invocations over the tunnel. It opens
ModuleTunnelAPI/InvokeAction, a reverse stream: the module connects and waits, and AutoFlow sends anActionInvocationfor each call a workflow makes. The invocation names the action and the workflow and carries the call as the workflow made it, positionalargsand keywordkwargsasValues, plus one send-only token per channel among the arguments, the workflow’s annotations and its namespace. The module answers oneResult: aValueon success, or anErrorwith a message and aretryableflag. Binding the arguments to typed parameters is the module’s job. - Deduplicate on the idempotency key.
ActionInvocation.idempotency_keyidentifies 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.validatoris aprotovalidate.Validator, fromprotovalidate.New().WithAction[P](name, fn, opts...)registers an action whose parameters areP.fnhas the typeActionFunc[P]:func(ctx context.Context, inv *ActionInvocation, params P) (*Result, error). Explicit presence makes scalar fields pointers, hence*p.Name.WithPositionalParams(1, "name")makesnamepositional-only and required by position;languagestays keyword-only. Sogreet("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 oninv.IdempotencyKey.WithVarsexposes frozen values a workflow definition imports by name.WithFiles(&autoflow.File{Name: "helpers.star", Data: src})ships Starlark a definition loads withload("module:greeting?file=helpers.star", ...).WithFileDescriptorProtodeclares proto types your values embed.WithRawActionhands the body the call as the workflow made it when no message can describe the arguments. PreferWithAction.
Results and errors
Return one of three shapes, built with the helpers in the SDK:
ResultOk(value): success. Build the value withString,Integer,Bool,Bytes,Float,Timestamp,Duration,None,List,Tuple,Set,DictwithKVpairs,Struct,SecretString,SecretBytesorMessage. 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. Onlypollcares: it counts as “not ready” and polls again. Outsidepollit 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 theeventmodule 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.