Skip to content

Latest commit

 

History

History

sdk-go

README.md

Dex Go SDK

Flow state I/O

Applications can read committed Attributes directly with Client.GetAttributes. Typed RPCs read staged state and perform writes. The Client does not expose direct Attribute writes, Channel publication, or pending-message mutation. This keeps each external state transition behind a Flow-owned method.

An RPC can stage channel.Delete(ctx, messageID). Register that RPC with dex.RPCOptions{IsTransactional: true} when a missing ID must abort every other RPC write. Attribute locking already selects transactional execution, while Channel deletion requires this explicit option.

RPC state loading

RPCs receive ordinary Attributes and all Channel size metadata by default. AttributeMap entries and pending Channel messages are opt-in:

func (flow *OrderFlow) GetRPCs() []dex.RPCDef {
	return []dex.RPCDef{
		dex.DefineRPC(flow.GetOrder, &dex.RPCOptions{
			LoadAttributeMapInstances: []dex.AttributeMapLoad{Items.Load("tenant-a")},
			LoadChannels:              []dex.ChannelDef{Queued},
			LoadChannelMaps:           []dex.ChannelDef{ByTenant},
		}),
	}
}

Put an AttributeMap or ChannelMap directly in its plural load option when every current instance is needed. Use the singular instance options for the less common exact-instance case. A selected empty queue returns an empty slice; reading an unselected map entry returns AttributeMapNotLoadedError. An unselected pending-message snapshot returns ChannelMessagesNotLoadedError. Pending messages preserve FIFO order and include the server-assigned message ID. The snapshot does not change after the handler stages a publish or deletion.

Loading controls which data reaches the Worker. IsTransactional controls atomic commit and Channel deletion validation. Attribute locks add isolation only among cooperating Steps and RPCs using the same lock. Write-only publish and delete operations do not require loading.

RPC names

An RPC's protocol name is its Go method name. Set RPCOptions.Name when a Flow type that another language defines names the RPC, so a Go Worker serves it and a Go Client invokes it. Go code still passes the bound method:

dex.DefineRPC(flow.SendUserMessage, &dex.RPCOptions{Name: "send_user_message"})

var queued bool
err := client.InvokeRPC(ctx, flowID, flow.SendUserMessage, message, &queued)

Invocation-time map instances

RPCOptions remains the registration-time contract for timeout, transactionality, locks, and fixed state loads. Use RPCInvokeOptions when the caller chooses an exact map instance at runtime:

partition := "partition-007"
err := client.InvokeRPCWithOptions(
	ctx,
	flowID,
	flow.UpsertCustomerProfile,
	profile,
	&result,
	dex.RPCInvokeOptions{
		LockAttributeMapInstances: []dex.AttributeLock{
			dex.LockAttributeMap(CustomerProfiles, partition),
		},
		LoadAttributeMapInstances: []dex.AttributeMapLoad{
			CustomerProfiles.Load(partition),
		},
	},
)

Invocation selections are additive. The Client unions, sorts, and deduplicates them with the registered selections. Locking and loading are independent; a read-modify-write RPC must request both for the same AttributeMap instance.

Runnable applications use this API in the sequential chunking and hash partitioning patterns. The product guides explain the ordered append layout and stable-key lookup layout.

Action permission projection

An RPC can declare a state-dependent Action in GetRPCs. Dex maintains the KeywordList Search Attribute DexWorkQueuePermissions:

func (flow RefundFlow) GetRPCs() []dex.RPCDef {
	return []dex.RPCDef{
		dex.DefineRPC(flow.ApproveRefund, &dex.RPCOptions{
			Action: dex.DefineAction(
				"Approve",
				dex.WhenAttributeMatches(
					caseStatus,
					dex.AttributeMatchEqual("awaiting-manager"),
					dex.AttributeMatchEqual("awaiting-agent"),
				),
				dex.ActionRequiresPermission("refund.approve"),
			),
		}),
	}
}

An Action has a non-empty display label, one Attribute condition, and exactly one required permission. A condition accepts one or more AttributeMatchEqual values and matches when any value equals the current Attribute value. The source must be a registered ordinary scalar Attribute. String, boolean, integer, finite floating-point, and named forms of those types are supported. A missing or deleted Attribute does not match. Permissions use lowercase alphanumeric segments separated by dots or hyphens, such as refund.approve.

The initial projection is the sorted union of every matching Action permission. Duplicate permissions are removed. StartFlow and SubFlow evaluate the initial Attributes directly. When a successful WaitFor, Execute, timeout handler, or RPC writes an Action source, the Worker sends the complete Action mapping with the business writes. The Server overlays those writes on its authoritative Attribute state and atomically adds newly matching permissions to the stored set. Unrelated writes and read-only RPCs do not send the mapping. Projection-only Attribute locks are not required.

The Server skips an upsert when no permission is added. Once a permission has matched, later state changes and empty mappings do not remove it. The set is kept for the life of the Flow and remains searchable after completion. An upgrade preserves permissions that are still stored but does not reconstruct permissions removed before the upgrade. A new, independent Flow execution starts from its own initial Attributes. A changed Action definition is first evaluated when a later source write sends its mapping or a new Flow starts.

DexWorkQueuePermissions is reserved for Server-managed runtime projection and is available as dex.WorkQueuePermissionsIndexKey for Work Queue searches. An application cannot register that name as an Attribute or custom IndexKey. The projection identifies Runs where an Action is or has been available. An Action gateway must authorize the selected RPC against its statically registered permission and current state. Search Attribute contents are never proof that a caller is authorized or that the Action remains available.

The Go SDK requires a Dex Server with protocol version 4 or later (server-zd newer than v0.1.16). Upgrade the Server before upgrading the SDK. Worker startup fails immediately against older Servers. Client construction stays lazy and performs no network I/O. Every Client method negotiates before its first request, and concurrent first calls share one GetServerInfo request. Each call's context, and the RequestTimeout of a durable wait, bounds both its wait for negotiation and its own request. Successful negotiation and a definitive protocol mismatch are cached. A failed GetServerInfo request returns a *ServiceError with Op GetServerInfo, like other request failures, and the next call retries. A Server whose minimum protocol is newer than the SDK reports that the Go SDK must be upgraded.

Step and timeout-handler state loading

StepOptions provides the same five selections independently for WaitFor and Execute: all AttributeMap instances, exact AttributeMap instances, Channels, all ChannelMap instances, and exact ChannelMap instances. The two methods receive independent snapshots. Execute reads after the winning Wait consumes messages; retries of one logical method call reuse its first snapshot.

FlowTimeoutHandlerOptions provides Execute-style timeouts, heartbeat detection, retry, durability, Attribute locks, and the same state selections. Set it on StartFlowOptions or SubFlowOptions only for a positive timeout using the handler policy. Exhausted retries may proceed to a registered Step[None]; read the final failure with Context.RecoveryError. Recognize Dex time limits with RecoveryErrorInfo.IsMethodTimeout, IsHeartbeatTimeout, and IsRetryTotalDurationExceeded. Do not compare ErrorType with a fixed string, because each Dex backend spells these error types differently. The methods return false for a nil RecoveryErrorInfo. When the call to the Worker fails before Dex records a method timeout, the failure is a Worker API failure instead. IsRetryTotalDurationExceeded recognizes historical records from before the upgrade. Current server-zd never ends a running attempt at the retry total duration. RetryPolicy.TotalDuration starts when the Step is first handed to a Worker and decides whether to schedule another retry after an attempt fails. It does not end a running attempt.

RecoveryErrorInfo.AttemptProgress reports progress across all attempts of the failed Step method. StepAttemptProgressNotStarted guarantees the handler never ran. StepAttemptProgressNotCompleted means an attempt may have run but none completed. Treat StepAttemptProgressUnspecified from an older Server like NotCompleted when deciding whether external side effects may have happened.

type timeoutRecoveryStep struct {
	dex.StepDefaultsNoWaitFor[dex.None]
}

func (timeoutRecoveryStep) Execute(
	ctx dex.Context,
	_ dex.None,
) (*dex.StepDecision, error) {
	failure := ctx.RecoveryError()
	if failure.IsMethodTimeout() || failure.IsHeartbeatTimeout() {
		return dex.ForceFail("timeout handler did not finish in time"), nil
	}
	if failure == nil {
		return dex.ForceFail("timeout recovery has no failure"), nil
	}
	return dex.ForceFail(failure.Detail), nil
}

The Go SDK is being rewritten around the current Dex Flow, Step, Attribute, Channel, Stream, WaitFor, and Execute contracts.

The rewrite provides the public model, value/protobuf mapping, immutable registration, application-hosted pull Workers, and typed FlowService Client.

License

Sustainable Use License 1.0, with legacy portions under their original terms as described in LEGACY_NOTICES.md.

Authoring a flow

Application packages import dex, never gen/dexpb.

var (
	OrderStatus = dex.DefineAttribute[string](
		"order-status",
		dex.Indexed(dex.AttributeIndex{Type: dex.IndexKeyword}),
	)
	Commands = dex.DefineChannel[Command]("commands")
	Progress = dex.DefineStream[string]("progress", 10<<20)
)

type WaitForCommandStep struct {
	dex.StepDefaults
}

func (WaitForCommandStep) WaitFor(
	ctx dex.Context,
	input OrderInput,
) (*dex.Wait, error) {
	var checkpoint string
	found, err := ctx.GetLastHeartbeatValue(&checkpoint)
	if err != nil {
		return nil, err
	}
	if !found {
		checkpoint = "starting"
	}
	if err := ctx.RecordHeartbeat(checkpoint); err != nil {
		return nil, err
	}
	if err := Progress.Write(ctx, "waiting for a command"); err != nil {
		return nil, err
	}
	if err := Progress.Write(ctx, "inventory check started"); err != nil {
		return nil, err
	}
	if err := OrderStatus.Set(ctx, "waiting"); err != nil {
		return nil, err
	}
	return dex.AnyOf(
		Commands.ForOne(dex.WithConditionID("command")),
		dex.Timer(
			30*time.Minute,
			dex.WithConditionID("timeout"),
		),
	), nil
}

func (WaitForCommandStep) Execute(
	ctx dex.Context,
	input OrderInput,
) (*dex.StepDecision, error) {
	if ctx.HasTimerFired() {
		return dex.ForceFail("command timed out"), nil
	}
	commands, err := Commands.GetConditionResults(ctx)
	if err != nil {
		return nil, err
	}
	return dex.GracefulComplete(commands), nil
}

var WaitForCommand = WaitForCommandStep{}

type OrderFlow struct {
	dex.FlowDefaults
}

func (OrderFlow) GetSteps() []dex.StepDef {
	return []dex.StepDef{
		dex.DefineStartStep(WaitForCommand),
	}
}

func (OrderFlow) GetPersistenceSchema() dex.PersistenceSchema {
	return dex.PersistenceSchema{
		Attributes: []dex.AttributeDef{OrderStatus},
		Channels:   []dex.ChannelDef{Commands},
		Streams:    []dex.StreamDef{Progress},
	}
}

Flows use dex.DefineStartStep for at most one starting step and dex.DefineStep for every non-starting step.

Execute-only steps embed dex.StepDefaultsNoWaitFor[IN]. Step transitions use dex.GoTo, or dex.MovementOf with dex.GoToMulti.

StepOptions.WaitForMethodTimeout and ExecuteMethodTimeout bound the two handler calls. Timer and channel conditions determine how long a Step waits. Use dex.Until(condition) for one condition, and dex.AllOf or dex.AnyOf for multiple conditions.

WaitForRetry and ExecuteRetry limit one logical handler execution across all attempts, including the ASYNC fast path and its fallback. Attempt numbers start at one. Total duration starts at the first hand-out to a Worker and decides whether to schedule another retry after failure; it does not interrupt a running attempt. The default is four hours.

ASYNC lowers latency and speeds up individual Flows; sustained throughput is about the same as SYNC. Step durability resolves from the method override, then FlowConfig, then StepDurabilitySync. Method timeouts default to two hours with a one-minute heartbeat timeout. The Server's minimum explicit heartbeat timeout defaults to ten seconds and can be changed by operators; the Go SDK validates only non-negative whole seconds in the signed int32 range.

Heartbeats and Stream progress

Context.RecordHeartbeat emits a checkpoint from WaitFor or Execute. A retry restores the most recent regular-activity checkpoint through Context.GetLastHeartbeatValue. Passing nil, including a typed nil, explicitly clears the checkpoint. Local-activity heartbeats are ignored. Flow timeout handlers and RPCs cannot send heartbeat or Stream progress.

Stream.Write emits a fire-and-forget frame on the same Worker response stream. A handler may write any number of messages to the same or different Streams. The call reports local validation, encoding, and gRPC send failures, but it does not wait for a Stream Store acknowledgment. Server-side store rejection or unavailability is visible through server logs and metrics rather than the handler return value. Heartbeats and Stream writes may run concurrently; their frames are serialized by the Worker.

Use NewBufferedTextStream for token-sized text deltas. It preserves text exactly, flushes after one second or a soft 16 KiB UTF-8 threshold, and emits the tail before the handler result or error:

progress, err := dex.NewBufferedTextStream(ctx, Thinking)
if err != nil {
	return nil, err
}
if err := progress.Write(delta); err != nil {
	return nil, err
}

BufferedTextStreamFlushInterval and BufferedTextStreamMaxBytes override the defaults. Empty buffers emit no message or heartbeat. Retry does not restore an unsent buffer or deduplicate batches already sent.

Messages written by a Step have #<stepExecutionID> in StreamMessage.Source. Client writes accept any non-empty source, including values containing #. Reusing a source appends another message; source is metadata, not an idempotency key.

Client.ReadStream reads forward one message at a time and can long-poll. Client.ListStreamMessages returns a non-blocking newest-first retained page. Pass the registered typed Stream directly, a positive page size, and an empty token for the first page. Pass StreamMessagesPage.NextPageToken unchanged to read older messages until it is empty. The server caps page size at 1000 by default. A trimmed anchor returns an empty page, and newer concurrent writes do not enter an existing older-page chain.

Running Step code outside a Worker

NewStandaloneContext creates a Context for running Step code outside a Worker, for example in a tool server or a test. It represents one handler attempt. It inherits cancellation, deadlines, and values from its parent context, and its metadata methods return the values in StandaloneContextOptions. Attempt is required and must be at least 1.

var lastHeartbeatValue any
if record.CheckpointJSON != nil {
	var checkpoint SendCheckpoint
	if err := json.Unmarshal(record.CheckpointJSON, &checkpoint); err != nil {
		return nil, err
	}
	lastHeartbeatValue = checkpoint
}
ctx, err := dex.NewStandaloneContext(attemptContext, dex.StandaloneContextOptions{
	FlowID:             "invoice-tool-server",
	StepExecutionID:    record.RequestKey,
	FirstAttemptAt:     record.FirstAttemptAt,
	Attempt:            record.Attempt,
	LastHeartbeatValue: lastHeartbeatValue,
	OnRecordHeartbeat: func(value any) error {
		var checkpointJSON []byte
		if value != nil {
			encoded, err := json.Marshal(value)
			if err != nil {
				return err
			}
			checkpointJSON = encoded
		}
		// SaveCheckpoint writes only if record.Attempt is still the stored attempt.
		return store.SaveCheckpoint(record.RequestKey, record.Attempt, checkpointJSON)
	},
})
if err != nil {
	return nil, err
}
decision, err := SendInvoiceStep{}.Execute(ctx, invoiceID)

The NewStandaloneContext API reference has the complete example.

Dex stores nothing for a standalone Context. GetLastHeartbeatValue and GetStepExecutionLocal read only the seeded LastHeartbeatValue and StepExecutionLocals. RecordHeartbeat, SetStepExecutionLocal, and RecordEvent validate and encode their value, then call the matching On* hook before returning, so the caller can persist the value and seed the next attempt.

  • Decode a value persisted as JSON into its original Go type, such as the checkpoint struct, int32, or []byte, before seeding it. Do not decode it into any: JSON numbers become float64 and byte slices become base64 strings, so decoding into the original type later fails. Do not seed the JSON itself as json.RawMessage or []byte.
  • After the parent context is done, the hooks never run. The three write methods then return an error that wraps the parent's error when the arguments are valid, the value encodes, and the event name is not already recorded; otherwise the validation error comes first. A write already in progress can still race with the end of its attempt, so a persisting hook must write conditionally on Attempt and never let an older attempt replace a newer attempt's value.
  • HasTimerFired, HasTimerFiredByIndex, and WaitForMethodFailed return false.
  • Attribute, Channel, Stream, and BufferedTextStream operations that return an error return an invalid invocation context error.
  • SubFlowResult and SubFlowID return errors stating that they require a Dex invocation Context.
  • These methods have no error result and panic on a standalone Context: Channel.Size, ChannelMap.Size, ChannelMap.MapSize, ChannelMap.AllInstanceKeys, AttributeMap.MapSize, and AttributeMap.AllInstanceKeys. Do not call them from Step code that runs outside a Worker.

Canceling Step executions

A successful Step can cancel queued or active executions while continuing with its normal decision:

return dex.GoTo(RecordQuote, quote).
	CancelSiblingSteps(CarrierA, CarrierB).
	CancelSteps(GlobalQuoteTimeout), nil

CancelSteps selects every current execution of each registered Step type. CancelSiblingSteps selects only executions whose Context.FromStepExecutionID() matches the current execution. Repeated calls form a union, and a Flow-wide selector wins for the same Step type. The Worker rejects nil, mismatched, or unregistered selectors as invalid Step results.

Dex resolves one snapshot after the current execution succeeds. Completed, already-canceled, and absent targets are no-ops. Next Steps created by the same decision are not in that snapshot. Dex immediately applies the next or close action without waiting for target handlers; late decisions, writes, retries, and recovery Steps are discarded.

An RPC may call RPCResult.CancelSteps for Flow-wide selection while also returning output and scheduling Next Steps. RPCs do not support sibling selection because an RPC invocation has no Step execution lineage.

Joining parallel branches

A Join waits for parallel branches of one Flow. It is a ChannelMap of branch results with the same name, registered in PersistenceSchema.Channels. Fork uses the split Step's execution ID as the round key, counts the branch builders, and builds every branch movement and the join movement with the same JoinRound:

var QuoteReceived = dex.DefineJoin[string]("quoteReceived")

func (RequestQuotes) Execute(ctx dex.Context, request QuoteRequest) (*dex.StepDecision, error) {
	movements, err := QuoteReceived.Fork(ctx,
		[]func(dex.JoinRound) dex.StepMovement{
			func(round dex.JoinRound) dex.StepMovement { return dex.MovementOf(QuoteFromSupplierA{}, round) },
			func(round dex.JoinRound) dex.StepMovement { return dex.MovementOf(QuoteFromSupplierB{}, round) },
		},
		func(round dex.JoinRound) dex.StepMovement {
			return dex.MovementOf(AcceptQuotes{}, QuoteJoinInput{Request: request, Round: round})
		},
	)
	if err != nil {
		return nil, err
	}
	return dex.GoToMany(movements...), nil
}

func (QuoteFromSupplierA) Execute(ctx dex.Context, round dex.JoinRound) (*dex.StepDecision, error) {
	return QuoteReceived.EndBranch(ctx, round, "A")
}

func (AcceptQuotes) WaitFor(_ dex.Context, input QuoteJoinInput) (*dex.Wait, error) {
	return dex.Until(QuoteReceived.ForAllBranches(input.Round)), nil
}

Each branch reports exactly one result per round with EndBranch or ReportBranchResult. The join Step waits with ForAllBranches, ForFirstBranch, or ForBranches(round, n) and reads BranchResults. A Flow that forks the same Join in a loop resets each round in the next split Step's WaitFor with ForRemainingBranches(previousRound, n) and reads the late results with RemainingBranchResults. RPCs have no Step execution ID and use ForkWithRoundKey. One invocation forks one Join at most once. Invalid counts fail when Dex serializes the Wait. Join.ChannelMap returns the registered ChannelMap, so an RPC can read or load a round's pending results with the round's RoundKey as the instance key. Never publish to it directly; report only with EndBranch or ReportBranchResult. The Join doc comment lists the traps, including canceled branches and canceling joins.

Soft Flow timeout

StartFlowOptions.Timeout starts a durable Dex timer rather than a backend execution timeout. A Flow implementing FlowTimeoutHandler defaults to TimeoutHandler; other Flows default to TimeoutFail. Override either default with TimeoutFail or TimeoutCancel:

func (OrderFlow) HandleTimeout(ctx dex.Context) (*dex.StepDecision, error) {
	return dex.ForceComplete("expired"), nil
}

timeout := 30 * time.Minute
options := dex.StartFlowOptions{
	Timeout:       &timeout,
	TimeoutPolicy: dex.TimeoutHandler,
}

The hook is Execute-only, receives no input, and runs at most once after its durable timer fires or is skipped. It may use Context normally and return any StepDecision. The deadline is preserved across Flow state transfers. Zero or nil timeout disables the feature.

Use dex.None when a Step, RPC, or Channel has no application payload, and pass nil at every call site. It rejects accidental values unlike any and makes the absence of a payload explicit.

Flow RPCs are methods matching dex.RPC[IN, OUT]. Attributes and channels stay strongly typed inside handlers. Step-execution locals and recorded events accept arbitrary values through dex.Context.

Invocation attribute reads return (value, error). A missing static or map attribute returns *dex.AttributeNotFoundError, which callers can inspect with errors.As when absence is expected.

Waiting and map inspection

Until, AllOf, and AnyOf use unnamed Conditions by default. Do not add Condition IDs merely because a Condition is nested in one of these waits. Every Condition in AnyComboOf must use WithConditionID. Reusing the same Condition value across combinations is supported, while duplicate IDs on distinct Conditions are rejected.

Clients wait on typed scalar comparisons in the current run with WaitForAttributeMatch or WaitForAttributeMapInstanceMatch. Build matches with AttributeMatchEqual, AttributeMatchNotEqual, AttributeMatchGreaterThan, AttributeMatchGreaterThanOrEqual, AttributeMatchLessThan, or AttributeMatchLessThanOrEqual. The matched value is decoded into the output pointer. String and bool support equality operators; integers and doubles support every operator. Every AttributeMap and ChannelMap instance must be non-empty and must not contain /. Objects, bytes, null, non-finite doubles, and invalid ordering fail before the RPC.

Request IDs are optional for both waits. The Server does not read them for these waits. RequestTimeout bounds the total SDK call across transport reattachments; zero waits indefinitely. Request expiry returns *dex.RequestTimeoutError. A caller disconnect releases its wait slot.

Inside a handler, AttributeMap.MapSize and AllInstanceKeys include buffered sets and deletes. ChannelMap.MapSize and AllInstanceKeys are RPC-only and include buffered publishes, but omit empty instances. Keys are decoded and sorted. Use ForceCompleteIfChannelsEmpty for atomic conditional completion.

SubFlows are normal, independently addressable Flows used as durable Conditions:

func (ParentStep) WaitFor(_ dex.Context, input ChargeInput) (*dex.Wait, error) {
	return dex.Until(dex.SubFlow(ChargeFlow{}, input)), nil
}

func (ParentStep) Execute(ctx dex.Context, _ ChargeInput) (*dex.StepDecision, error) {
	result, err := dex.SubFlowResult(ctx)
	if err != nil {
		return nil, err
	}
	var receipt Receipt
	if err := result.DecodeSingleOutput(&receipt); err != nil {
		return nil, err
	}
	return dex.GracefulComplete(receipt), nil
}

SubFlowID(ctx, index...) returns the generated identity, including for a running AnyOf loser. SubFlowOptions configures timing, timeout policy, retry, initial target Attributes, Flow config, Condition ID, and reuse. Parent completion does not cancel an unfinished SubFlow.

Registration

Registration uses Go type names without package or pointer by default: *orders.OrderFlow becomes OrderFlow. Name each Flow type after its domain, such as ApprovalFlow, and never just Flow: the package does not disambiguate Flow types in one Registry, and Dex Web shows the name as is. Embed FlowDefaults in a Flow. Waiting steps embed StepDefaults; execute-only steps embed StepDefaultsNoWaitFor[IN]. Both include the default step type and options. Override GetFlowType or GetStepType only when an explicit durable identity is required. A generic Flow or Step type has no default name and must override it. dexcli visualize and Dex Web v2 use these same registered names.

Registration is assembled once from each Flow's final durable type, steps, persistence schema, and exported RPC methods. It rejects empty or duplicate names, multiple starting steps, invalid indexes, undeclared locks, and incompatible execute-failure targets before the Worker starts.

Worker.Start aggregates every registered Indexed Attribute and synchronizes the physical index names with Dex Server before opening task streams. Existing indexes return immediately; a synchronization failure or the default two-minute deadline fails startup. Indexed AttributeMaps must declare a fixed index key.

Attribute Store synchronization

Opt an Attribute or AttributeMap into the Flow's external latest-state projection, then select Server-configured Attribute Stores on the Flow:

var CustomerEmail = dex.DefineAttribute[string](
	"customer-email",
	dex.SyncToAttributeStore(),
)

config := &dex.FlowConfig{
	AttributeStoreNames: []string{"profiles", "audit"},
}

Store updates are asynchronous. Every enabled Attribute write is sent to every selected Store. Deleting an opted-in Attribute writes SQL NULL, and a Store failure does not roll back the Flow Attribute. A nil AttributeStoreNames preserves current targets; an empty non-nil slice disables future synchronization while preserving protocol presence.

Hard-delete Attributes

Use HardDeleteOnNull for short-lived secrets that must not stay in Flow history:

var ModelAPIKey = dex.DefineAttribute[string](
	"model-api-key",
	dex.HardDeleteOnNull(),
)

Dex stores the value only in its blob store, and Delete permanently removes it. Handlers receive the value inline. Only string and object values are supported, and the option cannot be combined with Indexed or SyncToAttributeStore. It requires a Server with an enabled blob store and protocol version 3. Each Flow keeps one stored value per key and overwrites it in place, so a retried Step or RPC attempt can observe a write or deletion made by a failed attempt. See the HardDeleteOnNull documentation for every limitation.

DefineStep and DefineStartStep retain the step input type behind a private typedStepDef that implements the sealed StepDef interface. Runtime movements resolve through the current Flow's registered step definitions, so a same-name Step value cannot replace the registered handler or defaults.

RPCs require no communication schema. A Flow lists its RPCs and immutable execution options explicitly through GetRPCs:

func (flow *OrderFlow) GetRPCs() []dex.RPCDef {
	return []dex.RPCDef{
		dex.DefineRPC(flow.GetOrder, nil),
		dex.DefineRPC(flow.UpdateOrder, &dex.RPCOptions{
			Timeout:        30 * time.Second,
			LockAttributes: []dex.AttributeLock{dex.LockAttribute(OrderStatus)},
		}),
	}
}

Each registered method uses this exact shape and keeps its Go method name as the durable RPC name:

func (
	ctx dex.Context,
	input IN,
) (*dex.RPCResult[OUT], error)

Only methods returned from GetRPCs are RPCs. Exported helpers and unregistered RPC-shaped methods are ignored. Each definition must contain a direct bound method on that Flow value; package functions, methods from another Flow, method expressions, closures, wrappers, duplicates, and invalid options fail Registry construction. Client calls pass the same direct bound method, such as Orders.Update, but do not pass RPC options. Return nil, err on failure; returning nil, nil is an invalid Worker result.

Registered Flow and Step values are retained and may be invoked concurrently. They must be immutable or concurrency-safe and must keep invocation state in dex.Context.

Running a Worker

Applications share one Registry and BlobCache between Worker and Client:

The cache comes from github.com/superdurable/dex/blob-cache-go/blobcache.

logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
registry, err := dex.NewRegistry([]dex.Flow{Orders})
if err != nil {
	return err
}
cache, err := blobcache.New(&blobcache.Config{
	Dir:      "/var/tmp/order-dex-blobs",
	MaxBytes: 1 << 30,
	Logger:   logger,
})
if err != nil {
	return err
}

worker, err := dex.NewWorker(registry, cache, dex.WorkerOptions{
	Logger: logger,
})
if err != nil {
	return err
}

client, err := dex.NewClient(registry, cache, dex.ClientOptions{
	Logger: logger,
})
if err != nil {
	return err
}
defer client.Close()

if err := worker.Start(); err != nil {
	return err
}

Workers receive tasks over outbound pull streams to FlowServiceAddress. They need only an outbound connection to the Dex Server. Every Client start and SubFlow uses pull routing automatically; no Worker target is configured. Credential authenticates the startup calls, task streams, and blob hydration. Set WorkerOptions.Pull to tune capacity, identity, registration, or shutdown. Nil uses the same pull defaults.

worker, err := dex.NewWorker(registry, cache, dex.WorkerOptions{
	FlowServiceAddress: "dex.example:8801",
	Pull: &dex.PullWorkerOptions{
		MaxConcurrentTasks: 8,
		StopDrainTimeout: 30 * time.Second,
	},
})
if err != nil {
	return err
}
client, err := dex.NewClient(registry, cache, dex.ClientOptions{
	FlowServiceAddress: "dex.example:8801",
})
if err != nil {
	return err
}
defer client.Close()
defer worker.Stop(context.Background())
if err := worker.Start(); err != nil {
	return err
}

Uploading Flow definitions

Since 1.45.0, a Worker can store the Flow Definition Graph (FDG 2.0) JSON of its Flows on the Dex server, so Dex Web does not need a local --flow-rendering-dir. WorkerOptions.FlowDefinitionPaths names files or directories; directories are read recursively for *.json files, and JSON files that are not FDG 2.x documents, such as package.json, are skipped. WorkerOptions.FlowDefinitions passes documents built in memory. Generate the files with dexcli visualize --schema-version 2.0 --json.

worker, err := dex.NewWorker(registry, cache, dex.WorkerOptions{
	FlowDefinitionPaths: []string{"flow-definitions"},
})

NewWorker returns an error for a missing path, an invalid named file, an invalid generatedAt, or a Flow type given twice. Start uploads only the documents of Flow types in the Registry, after it negotiates the Server protocol and before it opens task streams. An absolute source.path is uploaded as a path relative to the working directory, or as its base name. Upload failures, including an older server without the API, are logged as warnings; the Worker still starts, and Stop cancels an upload in progress. The server keeps the document with the latest generated time, so give every process of one release the same time. Deployment scripts can call dex.ReadFlowDefinitionFiles and Client.UploadFlowDefinitions instead, with force to roll a definition back.

Call WaitPullRegistered(ctx) after launching Start on a goroutine to gate startup and rolling deployments. Readiness requires at least one registered STEP_AND_RPC stream and, when ReservedRPCTasks is positive, one RPC_ONLY stream. Concurrent waits are safe. The context bounds the wait; startup failures retain their error chain. Once ready, later waits return nil even after Stop.

PullRegistrationError.Queue identifies the slot kind with its full POLL_WORKER_SLOT_KIND_STEP_AND_RPC or POLL_WORKER_SLOT_KIND_RPC_ONLY name, not a Flow type or Worker ID. A permanent rejection is fatal to readiness only when no other stream of that kind remains registered. PullRegistrationState keeps its existing Queues field, with one entry per required slot kind. Transient failures preserve readiness after initial registration. A successful registration clears that kind's permanent error. Remove stopping Workers from service separately because Stop preserves latched readiness. See the runnable startup and HTTP readiness example.

StopOnPermanentStreamRejection defaults to false, which retries permanent rejections. When enabled, a permanent rejection leaving a required slot kind without a registered stream makes Start drain all streams within StopDrainTimeout, then cancel them and wait for handlers before returning the registration error. Always call Stop before exiting.

The Worker keeps exactly MaxConcurrentTasks long-lived execution slot streams. Zero uses the CPU count. Each stream registers an empty queue, the Worker ID, and every registered Flow type; it is ready while idle and busy while executing one task. ReservedRPCTasks streams accept only RPCs; the others accept Steps and RPCs. The Worker executes every received task without capacity rejections. IdleStreamsPerQueue is deprecated, remains accepted, and has no effect. Start fails without background retry when the Server's first registration reports a nonzero max_concurrent_tasks_per_app_worker below MaxConcurrentTasks. The error names both values and matching.maxConcurrentTasksCap. The Worker then half-closes its streams, so a task sent right after registration returns to the Server without costing an attempt. A lower limit reported on a later reconnect only logs a warning; the Server rejects any surplus stream.

Unknown Step types and RPC names can still be rejected before the handler runs. Flow type names must be unique within a Dex Server.

The generated Worker ID uses the first nonempty value from DEX_WORKER_INSTANCE, POD_NAME, HOSTNAME, or the system hostname, plus a random suffix. An AppWorkerID override must identify one Worker process uniquely and change when that process restarts. It must contain 1-63 ASCII letters, digits, dots, underscores, or hyphens, and must not equal a registered Flow type. The Worker reconnects after stream failures. On a server-directed reconnect, an idle stream half-closes at once and a busy stream finishes its task and sends its final output first. It logs permanent registration failures and retries them after at least one minute. Start returns startup synchronization errors, not later poll stream errors. The SDK sends an upstream Pong every 20 seconds while a task runs and stops before its final frame. This internal interval needs no Worker option.

Start blocks and is one-shot. Stop(ctx) starts no new tasks. Idle streams immediately half-close their request side; busy streams finish the handler, send final output, then half-close. Tasks arriving after shutdown begins are dropped without calling a handler, including frames arriving after the half-close. Streams keep reading until the Server ends them. After StopDrainTimeout (default 30 seconds) or context expiry, Stop resets active streams and cancels handlers instead of half-closing a running task. The deadline also resets half-closed streams that the Server has not ended, such as after a network partition, and streams whose sends are blocked. The Server counts a task it sent to such a stream as possibly run. A stream whose handler already produced its final output, even one still waiting behind another frame, is not reset at the deadline: it half-closes, and Stop waits up to five more seconds for the Server to end it. Stop waits for handlers to return, so handlers must honor cancellation. Local stream failures, such as an oversized or undecodable frame, or a task with no task ID, an invalid deadline, or no request, always reset the stream and never half-close it; the handler does not run. Stop is idempotent. A broken connection cancels its tasks, and their output is never sent on a later connection. The SDK does not install signal handlers; the Order example shows signal-driven shutdown.

Each invocation receives independent buffered attributes, channel messages, locals, and events. Successful handlers commit the whole response. Returned errors, mapping failures, and recovered panics commit nothing. Flow and Step values may be called concurrently and must be concurrency-safe.

Blob-backed values hydrate through private FlowService calls. FlowServiceAddress defaults to localhost:8801. Registry and BlobCache are required; the caller closes the cache after Client and Worker stop using it. When LoadBlobs omits requested blobs, the error lists every missing blob ID, its Flow ID, and each affected Worker value: Step or RPC input, Attribute key, Channel name and message ID, Step-execution local key, heartbeat, or condition result. It also shows the resolved FlowServiceAddress. Check that the Worker's Flow service matches the Server dispatching the call. Worker error details are bounded to 1 KiB; when the complete diagnostic exceeds that limit, the SDK also logs every missing blob and value through its logger.

ClientOptions.GrpcMaxMessageBytes and WorkerOptions.GrpcMaxMessageBytes limit the gRPC messages the Client or Worker receives. Zero uses dex.DefaultGrpcMaxMessageBytes (32 MiB), which matches the Dex server's default api.grpcMaxMessageBytes, and keeps gRPC's unlimited send limit, so the server bounds what the SDK sends. A positive value limits both directions; negative values are invalid. The Worker applies the limit to its outbound task streams, which receive Step and RPC invocations, and to its FlowService connection. Every hop applies the smaller limit of its two ends, so raise the server and SDK limits together. Keep the limit above the largest application value by an explicit margin for the message envelope; the default leaves 16 MiB of headroom for values up to 16 MiB.

Hydration first requests every uncached blob in one LoadBlobs call. Blob references do not carry sizes, so when a response exceeds a gRPC message limit, the SDK splits the request in half and retries each half. N uncached blobs take at most 2N-1 calls. A single blob that still does not fit returns *dex.BlobMessageTooLargeError with its Flow ID and blob ID.

dex.Logger supports structured debug, info, warning, and error messages. blobcache.Config.Logger defaults to slog.Default and is inherited by Client and Worker. Their option-level loggers override it for that component.

Reserved RPC capacity

Set ReservedRPCTasks when synchronous RPC latency must stay responsive behind long-running Steps. When unset, it reserves one slot if the resolved task limit is at least two, otherwise zero. With the CPU-count default, Steps get one fewer slot than before. Set it explicitly to zero to opt out. With one slot and no explicit reserve, Worker start logs one warning: RPCs may wait behind long-running Steps; use a limit of at least two or set the reserve explicitly. Flows must stay responsive because RPCs share Worker task capacity with Steps. The value must be nonnegative and less than resolved MaxConcurrentTasks; invalid values fail construction. Reserved streams register as RPC_ONLY so Steps use the other slots without being declined. RPCs can use either kind. For example, eight task slots with two reserved slots means six shared streams and two RPC-only streams:

pull := dex.PullWorkerOptions{MaxConcurrentTasks: 8, ReservedRPCTasks: ptr.Any(2)}

PullReservedRPCDeclines() is deprecated and always returns zero.

FlowService TLS

FlowServiceAddress uses plaintext gRPC by default for both the Client and Worker, including pull mode. For a TLS-terminating Dex endpoint, use its host:443, dns:///host:443, or dns://authority/host:443 address and set FlowServiceTLS on both the Client and Worker. TLS requires version 1.2 or newer and negotiates ALPN h2. The certificate name defaults to the address host. Extra PEM root CAs extend the system roots; they do not replace them. A non-nil empty option uses only the system roots. TLS never falls back to plaintext. Existing bearer, file, and API key credentials work over either transport. The API key token endpoint has its own HTTPS trust configuration.

rootPEM, err := os.ReadFile("/etc/dex/root-ca.pem")
if err != nil {
	return err
}
tlsOptions := &dex.FlowServiceTLSOptions{AdditionalRootCAsPEM: rootPEM}
worker, err := dex.NewWorker(registry, cache, dex.WorkerOptions{
	FlowServiceAddress: "dex.example.com:443",
	FlowServiceTLS: tlsOptions,
	Pull: &dex.PullWorkerOptions{},
})
if err != nil {
	return err
}
client, err := dex.NewClient(registry, cache, dex.ClientOptions{
	FlowServiceAddress: "dex.example.com:443",
	FlowServiceTLS: tlsOptions,
})
if err != nil {
	return err
}
defer client.Close()
defer worker.Stop(context.Background())
if err := worker.Start(); err != nil {
	return err
}

Without ServerName, the HTTP/2 :authority is the address host and port, as with plaintext, and an IPv6 address keeps its brackets. The DNS server in a dns://authority/host:port address never becomes the certificate name or :authority. Set ServerName only when the certificate name differs from the address host. It is then also sent as :authority, without a port. An IPv6 ServerName produces an unbracketed :authority that strict HTTP/2 servers reject, so leave it empty for an IPv6 address or use a DNS name. An IP address sends no SNI and requires a matching IP SAN. An AdditionalRootCAsPEM with no parseable certificate fails NewClient or NewWorker. The other SDKs will gain the FlowService TLS option in a later release.

Calling FlowService

Client methods target the current run by sending an empty run ID. This always reaches the Flow's current run. StartFlow and TimeTravel return run IDs for diagnostics, while normal calls need only the flow ID.

ClientOptions.Credential and WorkerOptions.Credential send a bearer credential with every FlowService call. A Dex server with authentication enabled requires one on every public API call. dex.FileCredential(path) reads a token file, such as a projected service account token, and rereads it when it changes; an emptied or missing file fails calls instead of sending a stale token. The Worker sends its credential with startup index verification and blob hydration.

Use dex.APIKeyCredential(tokenURL, serverID, apiKey) when the application holds a long-lived API key instead of an access token. The credential posts {"server_id":"<server ID>","api_key":"<API key>"} to the token URL and sends the returned bearer access token. The server ID is the identifier that the token endpoint scopes the access token to; supply it explicitly, because the SDK never derives it from a host name.

credential, err := dex.APIKeyCredential(
	"https://auth.example.com/oauth/token",
	"orders-production",
	os.Getenv("DEX_API_KEY"),
	dex.WithAPIKeyExchangeTimeout(5*time.Second),
)
if err != nil {
	return err
}
client, err := dex.NewClient(registry, cache, dex.ClientOptions{Credential: credential})
  • Construction validates the arguments without contacting the token endpoint. The token URL must use https; http is allowed only for localhost, 127.0.0.0/8, and ::1.
  • The first call performs the exchange. The token is cached until 80% of its expires_in lifetime has elapsed. The next call then starts one background refresh and keeps using the cached token.
  • At most one exchange runs at a time, and concurrent callers share its result. A failed refresh keeps serving the cached token and is retried at most once every 5 seconds. After the token expires, a call waits for the exchange, bounded by WithAPIKeyExchangeTimeout (default 10 seconds), and fails with the exchange error if no token arrives.
  • The token's expiry counts from the start of the exchange request. If a slow response arrives after that expiry, the exchange fails and the token is never cached or sent.
  • When Dex rejects a call with UNAUTHENTICATED, the SDK discards that token if it is still cached. A unary call is retried exactly once with a fresh token. A rejected pull Worker poll stream discards its token and reconnects once immediately with a fresh token. If that reconnection is rejected too, the Worker falls back to its usual reconnection, which waits about a minute.
  • The credential never follows redirects and never includes the API key or an access token in errors or formatted output. It replaces the API key and known access tokens with [REDACTED] in the token endpoint's error and error_description fields and in transport errors. It also redacts every JWT-shaped substring there: three base64url segments separated by dots, each at least 10 characters long. This covers an older token that the credential no longer remembers. WithAPIKeyHTTPClient supplies a proxy-aware or test HTTP client.
  • The application owns the credential and may share it between several Clients and Workers, so closing a Client or Worker does not stop it. The credential starts no long-lived goroutine and needs no Close. An exchange goroutine ends within one exchange timeout when the HTTP transport honors request context cancellation, as the default transport does.

StartFlow uses the starting step retained by Registry. Its input must match that step's input type, and its step options come from the registered Step. Flows without a starting step require nil input.

The SDK generates request IDs for StartFlow and InvokeRPC. A StartFlowOptions.RequestID may provide a stable business identifier spanning separate calls. Step completion and Attribute waits accept an optional request ID, but the server does not read it.

A stable request ID alone does not make a StartFlow retry succeed. A retried StartFlow returns the existing run only when AlreadyStarted.IgnoreError is true and the request ID matches the one that started that run. Without IgnoreError, the retry returns FlowAlreadyStartedError even when the first attempt created the Flow. The match applies only when the ID reuse policy rejects the start; a policy that permits another run starts a new one. Set both options on every attempt of one logical start:

flowID := "order-" + orderID
requestID := "start-order-" + orderID
runID, err := client.StartFlow(ctx, orderFlow, flowID, input, dex.StartFlowOptions{
	AlreadyStarted: &dex.AlreadyStartedOptions{IgnoreError: true},
	RequestID:      &requestID,
})

Client is safe for concurrent calls. Close is idempotent and closes only its owned gRPC connection. Calls after Close return a local error.

WaitForFlow hydrates every requested completion before returning. Decode one known output with result.DecodeSingleOutput(&output); it returns a local error unless exactly one completion exists. For multiple outputs, match result.Completions by StepType or StepExecutionID, then call completion.Output.Decode(&target). The slice preserves server collection order, but parallel branch order is not deterministic. No-output Flows return an empty slice.

DescribeFlow(ctx, flowID) reads one known Flow's current or latest run without waiting. Use it for a one-off status check, for example when a read-only RPC returns a non-terminal business state and the caller needs to know whether the Flow has failed. Its FlowInfo contains FlowID, RunID, FlowType, Status, StartedAt, and ClosedAt. ClosedAt is zero while the run is open or when the close time is unavailable. Use WaitForFlow when the caller needs to wait for a terminal result, and SearchFlows to find Flow IDs by query.

GetAttributes reads selected registered Attributes in one server call, without dispatching a Worker. A read sees committed state, including on a closed run until it is purged. It does not see writes staged inside an active RPC. Pass ReadAttribute or ReadAttributeMapInstance with a *T destination for each value:

orderStatus := dex.DefineAttribute[string]("order-status")
notes := dex.DefineAttributeMap[Note]("notes")
var status string
var note Note
result, err := client.GetAttributes(ctx, flowID,
	dex.ReadAttribute(orderStatus, &status),
	dex.ReadAttributeMapInstance(notes, "n1", &note),
)
if err != nil {
	return err
}
if !result.IsSet(orderStatus) {
	status = "new"
}

An unset Attribute or AttributeMap instance leaves its destination unchanged. Most callers ignore the returned AttributeReadResult; use IsSet or IsMapInstanceSet when an unset value must be told apart from a stored zero value. Destinations are written only after every value decodes, so an error never leaves partial writes. The servers resolve hard-delete values, and the Client resolves large-value references through LoadBlobs before decoding. An unregistered definition, or one whose value type differs from the registered definition, returns *FlowDefinitionError. A missing Flow or a run being purged returns *FlowNotFoundError, and a decode failure returns *ValueMappingError.

Remote FlowService failures use concrete Go error types. Match expected conditions with errors.As instead of comparing gRPC codes or sub-statuses:

var inactive *dex.FlowNotActiveOrNotFoundError
if errors.As(err, &inactive) {
	// The Flow is missing or already closed for this mutation.
}

FlowNotFoundError is returned by operations requiring an existing Flow, including DescribeFlow, GetAttributes, WaitForFlow, and TimeTravel. RPCs, timer operations, and step or attribute waits return FlowNotActiveOrNotFoundError when no active Flow is available.

RPCs also use this error when the target Flow cannot be found, including query-only reads. A query-only RPC without locks, transactional execution, durable effects, or Server-forced Update routing can read a retained terminal execution. At that confirmed read boundary, this error means no readable target was found; return the application's missing or unavailable result directly, without a lifecycle probe, a short timeout, a retry, or historical Step-output decoding. Preserve other service and Worker failures. For mutations and active-only RPCs, the error can mean either missing or closed and does not prove that the requested action succeeded. RPC success does not prove that the Flow is active.

Duplicate starts return FlowAlreadyStartedError. Server long polls return LongPollTimeoutError, including the operation and Flow ID. Durable Step and Attribute waits reattach transparently instead of exposing that error. Worker handler failures return WorkerInvocationError; its Worker field preserves the original Worker code, type, and detail. Concurrent locking RPCs return RPCLockConflictError separately.

Update-class requests rejected during Flow close cleanup return FlowClosingError with ErrorSubStatusFlowClosing and gRPC FailedPrecondition. Re-read the retained terminal Flow state before deciding whether another action is needed.

WaitForFlow returns FlowResult for every terminal status. Inspect Status, ErrorType, ErrorMessage, and any requested Completions; transport and long-poll failures remain errors.

Every specific service error unwraps to *dex.ServiceError and then to the original gRPC error. ServiceError exposes Op, FlowID, Code, SubStatus, and Detail; sub-status is diagnostic metadata, not application control flow. Unknown or malformed server details fall back to ServiceError.

Registry failures use FlowDefinitionError, invalid Worker handler results use InvalidStepResultError, and codec failures use ValueMappingError. Ordinary argument and context errors remain ordinary Go errors.

Value encoding

Strings, booleans, signed integers, representable unsigned integers, and floating-point values use native Dex value arms. Strings must contain valid UTF-8; use []byte for arbitrary binary data. Byte slices use an object arm with wire encoding "raw" and store the bytes directly without base64. Structs, maps, other slices, arrays, and JSON-compatible values use an object arm with wire encoding "json".

Internal Blob references are opaque. Hydration sends the owning Flow ID with each reference, and the local cache is isolated by (flowID, blobRef).

Returned dynamic values remain opaque until decoded:

var result OrderResult
if err := value.Decode(&result); err != nil {
	return err
}

Decode requires a non-nil pointer. Invalid UTF-8 strings, integer overflow, incompatible targets, unknown encodings, unhydrated blob references, and malformed JSON return errors.

Ordinary nil encodes with the Dex null arm. In an Attribute write, that arm deletes the Attribute. In every other Value context, it represents null.

Indexed attributes

Keyword and text indexes accept valid UTF-8 strings. Keyword-array indexes accept slices containing valid UTF-8 strings. Int, double, and bool indexes accept their matching Go primitive types. Datetime indexes accept time.Time or RFC3339Nano strings, including UTC Z and numeric offsets. Fractional seconds are preserved. Numeric strings are not treated as Unix nanoseconds. Initial indexed values are validated by dex.InitialAttribute and dex.InitialAttributeMapValue.

The SDK generates UUIDs for StartFlow and InvokeRPC. StartFlowOptions.RequestID may override the generated start ID. Step completion and Attribute waits accept a request ID, but the server does not read it.

Large string and object values may be returned as blob references. Worker inputs and Client results hydrate before handler or application decode. Decode never performs network I/O.

Compilable examples:

Verification

make unitTests
make clientIntegTests
make workerIntegTests
make e2eTests
make docsCheck
make copyright-check

docsCheck parses the hand-written dex, logging, and shared blobcache packages and requires a Go doc comment on every exported type, function, method, field, interface method, constant, and variable. Comments must begin with the declared name. Generated protobufs and tests are excluded. Use go doc dex.<Name> (or an IDE hover) to inspect the same application-facing API documentation locally.

e2eTests uses the current checkout's dexcli dev environment. It runs the migrated iWF Go SDK scenarios through the public Dex SDK. It also visualizes integ/webv2approval and integ/webv2reply into a Flow rendering directory and checks that Dex Web v2 lists, searches, displays, edits, acts on, and starts both Flows under the names the Worker registers. DEX_INTEG_DEX_PORT and DEX_INTEG_WEB_PORT pin the Dex gRPC and Dex Web ports; each defaults to a free port. Each script run starts a fresh dexcli dev environment with server-zd and pull Workers. DEX_INTEG_ENGINE=zd remains accepted. Set DEX_SERVER_ZD_BINARY to reuse a built dex-server-zd.

integTests runs ./integ against a Dex you start yourself. Start it with dexcli dev -flow-rendering-dir pointing at the two visualized fixtures, and set DEX_FLOW_SERVICE_ADDRESS and DEX_WEB_ADDRESS (default 127.0.0.1:8801 and 127.0.0.1:8802).

Measure integration coverage

Run the local Client and Worker integration suites plus all dexcli dev E2E scenarios with Go coverage:

make integrationCoverage

The report measures only production packages under ./dex/.... Unit tests, standalone BlobCache module tests, examples, generated protobuf stubs under gen/, and *_test.go files are excluded. Open coverage/index.html for annotated source, or inspect coverage/coverage.txt for per-function totals. coverage/coverage.out is the profile uploaded by CI.

CI uploads the profile to Codecov with GitHub OIDC, so no upload secret is stored in this repository. The report uses the sdk-go-integration flag and contributes to the Go SDK component defined in the root codecov.yml. The Actions run also publishes the report directory as sdk-go-integration-coverage.

The detailed design and later phase boundaries are in the Go SDK rewrite plan.

Server protocol compatibility

Worker startup calls GetServerInfo, negotiates the highest version in the intersection of the Server and Go SDK protocol intervals, synchronizes Attribute indexes, and then opens task streams. The SDK interval is [4,4]. Missing server information, invalid or disjoint intervals, and RPC failures stop startup before polling. The negotiated protocol is retained for future protocol-specific paths.

The diagnostic SDK version comes from Go build information for the github.com/superdurable/dex/sdk-go module. Source builds without module version metadata report dev; neither value changes compatibility decisions.

Search Flow runs

Searches return runs that match the query. Include FlowType when using shared index slots, and keep the query and page size consistent across pages.