Use a workflow client to start a process and return to it from later requests or
another service. These examples assume a configured driver, an
operation context ctx, and the confirmation-aware
Onboard definition.
The facade is safe for concurrent use and does no I/O during construction. The driver owner remains responsible for startup and cleanup:
import "arkoren.dev/core/service/workflow"
client := workflow.NewClient(driver)
Starting Runs
Choose a stable business identifier so duplicate requests refer to the same
process. For an already validated signup represented by User:
run, err := client.Start(ctx, Onboard, "user-42", user)
if err != nil && !errors.Is(err, workflow.ErrAlreadyStarted) {
return err
}
This fragment uses the standard errors package. Success returns a typed handle,
not the workflow’s result. A duplicate returns that existing handle together
with ErrAlreadyStarted; it does not replace the original input or restart the
run. Protection lasts only while the provider retains the execution record.
Temporal IDs are unique across workflow names in a namespace, so include enough
business context to avoid collisions.
An empty ID or one containing ASCII control characters fails with
workflow.ErrInvalidID before submission. Invalid workflow declarations and
payload encoding can also fail before driver I/O. A transport failure after
submission can leave the outcome uncertain: the engine may have accepted work.
Reconcile the same logical ID rather than starting a replacement with a new ID.
Starting a run is not atomic with committing your application database. If the handoff must survive a crash between those operations, persist the intent through an application-owned outbox or equivalent durable mechanism. A successful Start also does not make every external effect exactly once; steps still need idempotency.
For a deferred start, add a delay without sleeping in the caller:
run, err := client.Start(ctx, Onboard, "user-42", user,
workflow.Delay(time.Minute),
workflow.Metadata(map[string]string{"source": "signup"}))
The driver schedules the delay. Metadata copies the outer map and is visible through provider operational interfaces where supported; keep secrets and personal data out of it. Native timing, metadata, and retention limits are on Drivers.
Reading Results
A later request can recreate a typed handle without a network call:
run := client.Run(Onboard, "user-42")
status, err := run.Status(ctx)
The handle itself does not establish existence. Status queries the driver and
returns workflow.Running, Completed, Failed, or Canceled; unknown or
expired records return ErrNotFound. Running includes a delayed run that has
not executed yet.
To obtain the typed output, wait for the run to finish:
token, err := run.Result(ctx)
Result returns the confirmation token, "expired" for the workflow’s ordinary
confirmation deadline, or the run’s failure. The context limits only this wait:
if ctx ends, Result returns its context error and the run continues. Ask again
with a fresh context and the same ID. Error Handling explains
which error identities survive the worker boundary.
Signaling Runs
After verifying a confirmation request and authorizing it for the target user, send the token through the shared signal definition:
err := client.Signal(ctx, Onboard, "user-42", EmailConfirmed, token)
This accepts delivery to the identified run; it does not wait for application
processing or completion. Unknown or finished runs return ErrNotFound. A signal
accepted before the workflow reaches Wait is retained, and the first value for
that name resolves the run’s signal. See
Signals and Waiting for repeated waits
and retention boundaries.
The workflow name and ID are routing information, not authorization. Keep untrusted callers away from raw engine submission, and resolve which run they may signal in application code rather than trusting an arbitrary request ID.
Canceling Runs
To request that the process stop, cancel the run rather than the context of a Result call:
err := run.Cancel(ctx)
Canceling a known finished run succeeds without replacing its result. An unknown
run returns ErrNotFound. A successful call observes cancellation acceptance,
not forced termination of application code or proof that all cleanup completed.
Bodies must cooperate with cancellation, and external effects already performed
are not undone.
Inspect the resulting status or result when the terminal outcome matters. Restate pre-execution cancellation can lose the record without its required retention profile. Neither cancel acceptance nor a missing lookup authorizes reusing a business ID without reconciliation. Worker shutdown is a separate host lifecycle concern.
Inspecting Definitions
To inspect registered worker policy rather than live runs, query the registry you constructed at startup:
retried := registry.Steps().Filter(func(_ int, handler workflow.StepHandler) bool {
return handler.Options.Attempts > 1
})
Steps and Workflows return eager collection.Slice snapshots sorted by name;
filtering preserves that order. You can group workflows by their run budget:
byDuration := registry.Workflows().GroupBy(func(_ int, handler workflow.WorkflowHandler) time.Duration {
return handler.Options.MaxDuration
})
Each group preserves name order. These records are not execution histories or live queries. Editing a returned record cannot replace a registered handler or policy. Functions, their captures, and services remain shared; collection operations do not create ownership or replay isolation.
Events and Telemetry
Client events describe successful local calls, not engine transitions. Given
your application app, enable them when constructing its client:
client := workflow.NewClient(driver, func(client *workflow.ClientBuilder) {
client.Events(app.Events())
})
Start emits workflow.run.started, Signal emits workflow.run.signaled, and
Cancel emits workflow.run.canceled. Payloads contain the workflow name and run
ID, plus the signal name where relevant; they omit input, output, and signal
values. Failed calls emit nothing. A duplicate Start is silent; a successful
Cancel of a finished run still emits. There is no portable terminal engine
subscription, and polling emits no completed or failed event. See
Built-in Events.
Configure telemetry independently in clients and
workers. Given a started service observations, replace ordinary registry
construction with worker registration:
registry, err := workflow.NewRegistry([]workflow.Definition{Onboard, SendWelcome},
func(worker *workflow.RegistryBuilder) {
worker.Telemetry(observations)
})
Pass the validated registry to the driver or native runner. Configure the client separately, even if both roles share a process:
client := workflow.NewClient(driver, func(client *workflow.ClientBuilder) {
client.Telemetry(observations)
client.Events(app.Events())
})
Nil telemetry follows the current process’s telemetry.Default() on every call
or actual attempt; explicit services remain shared. Export every process to a
common backend: a local event bus or memory capture does not collect observations
from other pods.
Client spans are workflow start, workflow signal, workflow cancel,
workflow status, and workflow result. Each call records
arkoren.workflow.operation.count and arkoren.workflow.operation.duration in
seconds, including validation failures. Duplicate starts retain their error and
handle but use the expected already_started telemetry outcome. Result duration
measures the caller’s wait and decoding, not durable execution time.
Workers observe workflow step_attempt only when a step handler actually runs.
Every retry is another attempt; replay, recorded results, suspension, signals,
and sleeps are not attempts. Dependency calls using the step’s ordinary context
can become child spans. An attempt records the handler’s return, not the engine’s
terminal decision; a successful attempt is not proof of successful run completion.
Use engine history for durable run timing and state, and do not add telemetry
side effects to replayable coordination code.
The start span travels in a separate persisted carrier, not in metadata or a Go context. Actual step attempts and supported child/spawn runs receive it; joining an existing run preserves its original parent. Signals observe submission and do not replace that parent. Retries and new child attempts retain the original start parent rather than a fabricated orchestration span. Check driver propagation and rollout before enabling traced producers. Carriers enter engine inputs or headers: allow only trusted submitters and keep sensitive baggage out of your propagator.
Automatic telemetry omits payloads, values, outputs, metadata, run IDs, and raw
error messages. Metric labels use only operation, outcome, and classified error
type. Client-call panics still propagate unchanged; a started observation closes
with workflow.panic, not success or the raw panic value. Application logging
through c.Logger() needs its own redaction policy.
Native SDK operations bypass these observations and local client events. Custom native scheduling outside the adapter must explicitly carry propagation.
For declarations and options, see the
workflow API reference.