Files
zeekayandHanzo Dev 679ae61163 feat(transport): address the engine and the SDK, so both can use a socket
The engine took a PORT and the SDK an Options.HostPort, so neither end
could use a unix socket for traffic that never leaves the host. The
field name was not the only obstacle: luxfi/zap bound net.Listen("tcp",
":port") and reached a peer with net.DialTimeout("tcp", addr), so a path
was refused at the dialer with "missing port in address" and could not
be bound at all. That is fixed upstream (luxfi/zap Address + Network);
this pins the commit until it is tagged.

EmbedConfig.Address, Embedded.Address, ServeGated(addr) and
client.Options.Address each take ONE address, because a port is a detail
of one transport rather than a thing every listener has. A path binds or
dials a unix socket; anything else is host:port.

TestDialRequiresHostPort became TestDialRequiresAddress: Dial needs an
address, and asserts a socket path is refused for being absent rather
than for being portless. TestE2E_UnixSocket runs a real workflow over a
socket end to end, and TestE2E_UnixSocketRebind proves the socket is
unlinked on stop so a restart binds cleanly.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-28 16:27:19 -07:00

524 lines
17 KiB
Go

package client
import (
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
"runtime"
"strings"
"time"
"github.com/hanzoai/tasks/pkg/sdk/converter"
)
// WorkflowRun is the handle returned by ExecuteWorkflow /
// SignalWithStartWorkflow / GetWorkflow. Get blocks until the workflow
// terminates and decodes the result into `valuePtr`.
type WorkflowRun interface {
// Get blocks until the workflow terminates and decodes the
// result into valuePtr. When the workflow continues-as-new the
// default behaviour follows the chain to the final run.
Get(ctx context.Context, valuePtr any) error
// GetWithOptions is the option-parameterised form of Get. When
// opts.DisableFollowingRuns is true, Get stops at the current
// run rather than following a continue-as-new chain to the final
// run.
GetWithOptions(ctx context.Context, valuePtr any, opts WorkflowRunGetOptions) error
GetID() string
GetRunID() string
}
// WorkflowRunGetOptions tunes WorkflowRun.GetWithOptions. Mirrors the
// upstream shape so caller code compiles unchanged after the import
// swap.
type WorkflowRunGetOptions struct {
// DisableFollowingRuns, when true, prevents Get from walking
// the continue-as-new chain. It returns when the current run
// terminates even if a successor run was started.
DisableFollowingRuns bool
}
// WorkflowExecutionInfo mirrors schema/tasks.zap WorkflowExecutionInfo
// for the v1 JSON wire. Exported so callers of DescribeWorkflow /
// ListWorkflows can inspect the result without pulling upstream types.
type WorkflowExecutionInfo struct {
WorkflowID string `json:"workflow_id"`
RunID string `json:"run_id"`
WorkflowType string `json:"workflow_type"`
StartTime time.Time `json:"start_time,omitempty"`
CloseTime time.Time `json:"close_time,omitempty"`
Status WorkflowStatus `json:"status"`
HistoryLength int64 `json:"history_length"`
TaskQueue string `json:"task_queue,omitempty"`
Memo map[string]any `json:"memo,omitempty"`
}
// WorkflowStatus mirrors the `status` Int8 in schema/tasks.zap.
type WorkflowStatus int8
const (
WorkflowStatusUnspecified WorkflowStatus = 0
WorkflowStatusRunning WorkflowStatus = 1
WorkflowStatusCompleted WorkflowStatus = 2
WorkflowStatusFailed WorkflowStatus = 3
WorkflowStatusCanceled WorkflowStatus = 4
WorkflowStatusTerminated WorkflowStatus = 5
WorkflowStatusContinuedAsNew WorkflowStatus = 6
WorkflowStatusTimedOut WorkflowStatus = 7
)
// ListWorkflowsResponse is returned by ListWorkflows.
type ListWorkflowsResponse struct {
Executions []WorkflowExecutionInfo `json:"executions"`
NextPageToken []byte `json:"next_page_token,omitempty"`
}
// startWorkflowRequest is the v1 JSON shape mapped onto schema/tasks.zap
// StartWorkflowRequest. Native ZAP serde replaces JSON in a follow-up.
type startWorkflowRequest struct {
Namespace string `json:"namespace"`
WorkflowID string `json:"workflow_id"`
WorkflowType string `json:"workflow_type"`
TaskQueue string `json:"task_queue"`
Input []any `json:"input,omitempty"`
RetryPolicy *retryPolicy `json:"retry_policy,omitempty"`
Timeouts timeouts `json:"timeouts,omitempty"`
Memo map[string]any `json:"memo,omitempty"`
CronSchedule string `json:"cron_schedule,omitempty"`
Identity string `json:"identity,omitempty"`
}
type retryPolicy struct {
InitialIntervalMs int64 `json:"initial_interval_ms,omitempty"`
BackoffCoefficient float64 `json:"backoff_coefficient,omitempty"`
MaximumIntervalMs int64 `json:"maximum_interval_ms,omitempty"`
MaximumAttempts int32 `json:"maximum_attempts,omitempty"`
NonRetryableErrorTypes []string `json:"non_retryable_error_types,omitempty"`
}
type timeouts struct {
WorkflowExecutionMs int64 `json:"workflow_execution_ms,omitempty"`
WorkflowRunMs int64 `json:"workflow_run_ms,omitempty"`
WorkflowTaskMs int64 `json:"workflow_task_ms,omitempty"`
}
type startWorkflowResponse struct {
RunID string `json:"run_id"`
}
type signalWorkflowRequest struct {
Namespace string `json:"namespace"`
WorkflowID string `json:"workflow_id"`
RunID string `json:"run_id,omitempty"`
SignalName string `json:"signal_name"`
Input any `json:"input,omitempty"`
}
type cancelWorkflowRequest struct {
Namespace string `json:"namespace"`
WorkflowID string `json:"workflow_id"`
RunID string `json:"run_id,omitempty"`
Reason string `json:"reason,omitempty"`
}
type terminateWorkflowRequest struct {
Namespace string `json:"namespace"`
WorkflowID string `json:"workflow_id"`
RunID string `json:"run_id,omitempty"`
Reason string `json:"reason,omitempty"`
}
type describeWorkflowRequest struct {
Namespace string `json:"namespace"`
WorkflowID string `json:"workflow_id"`
RunID string `json:"run_id,omitempty"`
}
type describeWorkflowResponse struct {
Info WorkflowExecutionInfo `json:"info"`
}
type listWorkflowsRequest struct {
Namespace string `json:"namespace"`
Query string `json:"query,omitempty"`
PageSize int32 `json:"page_size,omitempty"`
NextPageToken []byte `json:"next_page_token,omitempty"`
}
// ExecuteWorkflow implements Client.
func (c *clientImpl) ExecuteWorkflow(
ctx context.Context,
opts StartWorkflowOptions,
workflow any,
args ...any,
) (WorkflowRun, error) {
if opts.TaskQueue == "" {
return nil, errors.New("hanzo/tasks/client: StartWorkflowOptions.TaskQueue is required")
}
wfType, err := workflowTypeName(workflow)
if err != nil {
return nil, err
}
req := startWorkflowRequest{
Namespace: c.namespace,
WorkflowID: opts.ID,
WorkflowType: wfType,
TaskQueue: opts.TaskQueue,
Input: args,
CronSchedule: opts.CronSchedule,
Memo: opts.Memo,
Identity: c.identity,
Timeouts: timeouts{
WorkflowExecutionMs: opts.WorkflowExecutionTimeout.Milliseconds(),
WorkflowRunMs: opts.WorkflowRunTimeout.Milliseconds(),
WorkflowTaskMs: opts.WorkflowTaskTimeout.Milliseconds(),
},
}
if rp := opts.RetryPolicy; rp != nil {
req.RetryPolicy = &retryPolicy{
InitialIntervalMs: rp.InitialInterval.Milliseconds(),
BackoffCoefficient: rp.BackoffCoefficient,
MaximumIntervalMs: rp.MaximumInterval.Milliseconds(),
MaximumAttempts: rp.MaximumAttempts,
NonRetryableErrorTypes: append([]string(nil), rp.NonRetryableErrorTypes...),
}
}
var resp startWorkflowResponse
if err := c.roundTrip(ctx, opStartWorkflow, req, &resp); err != nil {
return nil, err
}
id := opts.ID
if id == "" {
// Server generated the ID; we have no way to learn it from the
// current response shape. The runId is authoritative either way.
id = resp.RunID
}
return &workflowRunImpl{id: id, runID: resp.RunID, client: c}, nil
}
// SignalWorkflow implements Client.
func (c *clientImpl) SignalWorkflow(
ctx context.Context,
workflowID, runID, signalName string,
arg any,
) error {
if workflowID == "" {
return errors.New("hanzo/tasks/client: workflowID is required")
}
if signalName == "" {
return errors.New("hanzo/tasks/client: signalName is required")
}
req := signalWorkflowRequest{
Namespace: c.namespace,
WorkflowID: workflowID,
RunID: runID,
SignalName: signalName,
Input: arg,
}
return c.roundTrip(ctx, opSignalWorkflow, req, nil)
}
// CancelWorkflow implements Client.
func (c *clientImpl) CancelWorkflow(ctx context.Context, workflowID, runID string) error {
if workflowID == "" {
return errors.New("hanzo/tasks/client: workflowID is required")
}
req := cancelWorkflowRequest{
Namespace: c.namespace,
WorkflowID: workflowID,
RunID: runID,
}
return c.roundTrip(ctx, opCancelWorkflow, req, nil)
}
// TerminateWorkflow implements Client.
func (c *clientImpl) TerminateWorkflow(ctx context.Context, workflowID, runID, reason string) error {
if workflowID == "" {
return errors.New("hanzo/tasks/client: workflowID is required")
}
req := terminateWorkflowRequest{
Namespace: c.namespace,
WorkflowID: workflowID,
RunID: runID,
Reason: reason,
}
return c.roundTrip(ctx, opTerminateWorkflow, req, nil)
}
// DescribeWorkflow implements Client.
func (c *clientImpl) DescribeWorkflow(ctx context.Context, workflowID, runID string) (*WorkflowExecutionInfo, error) {
if workflowID == "" {
return nil, errors.New("hanzo/tasks/client: workflowID is required")
}
req := describeWorkflowRequest{
Namespace: c.namespace,
WorkflowID: workflowID,
RunID: runID,
}
var resp describeWorkflowResponse
if err := c.roundTrip(ctx, opDescribeWorkflow, req, &resp); err != nil {
return nil, err
}
out := resp.Info
return &out, nil
}
// ListWorkflows implements Client.
func (c *clientImpl) ListWorkflows(ctx context.Context, query string, pageSize int32, nextPageToken []byte) (*ListWorkflowsResponse, error) {
req := listWorkflowsRequest{
Namespace: c.namespace,
Query: query,
PageSize: pageSize,
NextPageToken: nextPageToken,
}
var resp ListWorkflowsResponse
if err := c.roundTrip(ctx, opListWorkflows, req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// workflowRunImpl is the handle returned by ExecuteWorkflow.
type workflowRunImpl struct {
id string
runID string
client *clientImpl
}
// GetID returns the workflow ID.
func (r *workflowRunImpl) GetID() string { return r.id }
// GetRunID returns the run ID.
func (r *workflowRunImpl) GetRunID() string { return r.runID }
// Get is GetWithOptions(ctx, valuePtr, zero-options). It follows the
// continue-as-new chain to the final run.
func (r *workflowRunImpl) Get(ctx context.Context, valuePtr any) error {
return r.GetWithOptions(ctx, valuePtr, WorkflowRunGetOptions{})
}
// GetWithOptions blocks until the workflow terminates and decodes the
// result into valuePtr. Poll cadence is exponential (250ms → 5s cap) so
// the v1 wire does not need a dedicated long-poll history-fetch RPC.
//
// NOTE (v1 gap, tracked in schema/tasks.zap): DescribeWorkflowResponse
// does not yet carry a `result` field, so a completed-workflow result
// decode surfaces ErrNotImplementedLocally. Callers that only need to
// block until termination pass valuePtr=nil.
//
// opts.DisableFollowingRuns, when true, stops at a ContinuedAsNew
// terminal and surfaces it as an error. When false (default), a
// ContinuedAsNew transition is followed: the handle's runID is
// replaced by the successor and polling resumes.
func (r *workflowRunImpl) GetWithOptions(ctx context.Context, valuePtr any, opts WorkflowRunGetOptions) error {
backoff := 250 * time.Millisecond
const maxBackoff = 5 * time.Second
for {
info, err := r.client.DescribeWorkflow(ctx, r.id, r.runID)
if err != nil {
return err
}
switch info.Status {
case WorkflowStatusCompleted:
if valuePtr == nil {
return nil
}
// v1 schema: no result field on DescribeWorkflowResponse.
// Surface the gap explicitly rather than silently returning
// a zero value.
return ErrNotImplementedLocally
case WorkflowStatusFailed:
return fmt.Errorf("workflow failed: %s", info.WorkflowID)
case WorkflowStatusCanceled:
return fmt.Errorf("workflow canceled: %s", info.WorkflowID)
case WorkflowStatusTerminated:
return fmt.Errorf("workflow terminated: %s", info.WorkflowID)
case WorkflowStatusTimedOut:
return fmt.Errorf("workflow timed out: %s", info.WorkflowID)
case WorkflowStatusContinuedAsNew:
if opts.DisableFollowingRuns {
return fmt.Errorf("workflow continued as new: %s", info.WorkflowID)
}
// v1 DescribeWorkflow does not include the successor run
// id; clear it and let the next DescribeWorkflow resolve
// the current head. Server-side uses workflowID as the
// stable lookup key for the latest run.
r.runID = ""
// fallthrough: loop, re-poll
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(backoff):
if backoff < maxBackoff {
backoff *= 2
}
}
}
}
// signalWithStartWorkflowRequest is the v1 JSON shape for opcode
// 0x0066. It is the union of startWorkflowRequest plus signalName /
// signalInput — the frontend handler decodes it as
// signalWithStartReq in service/frontend/zap_handler.go.
type signalWithStartWorkflowRequest struct {
Namespace string `json:"namespace"`
WorkflowID string `json:"workflow_id"`
WorkflowType string `json:"workflow_type"`
TaskQueue string `json:"task_queue"`
Input []any `json:"input,omitempty"`
RetryPolicy *retryPolicy `json:"retry_policy,omitempty"`
Timeouts timeouts `json:"timeouts,omitempty"`
Memo map[string]any `json:"memo,omitempty"`
CronSchedule string `json:"cron_schedule,omitempty"`
Identity string `json:"identity,omitempty"`
SignalName string `json:"signal_name"`
SignalInput any `json:"signal_input,omitempty"`
}
// queryWorkflowRequest is the v1 JSON shape for opcode 0x0067.
type queryWorkflowRequest struct {
Namespace string `json:"namespace"`
WorkflowID string `json:"workflow_id"`
RunID string `json:"run_id,omitempty"`
QueryType string `json:"query_type"`
QueryArgs []any `json:"query_args,omitempty"`
}
// queryWorkflowResponse mirrors the frontend handler's queryResp.
type queryWorkflowResponse struct {
Result []byte `json:"result,omitempty"`
}
// SignalWithStartWorkflow implements Client. Opcode 0x0066.
func (c *clientImpl) SignalWithStartWorkflow(
ctx context.Context,
workflowID, signalName string,
signalArg any,
opts StartWorkflowOptions,
workflow any,
workflowArgs ...any,
) (WorkflowRun, error) {
if workflowID == "" {
return nil, errors.New("hanzo/tasks/client: workflowID is required")
}
if signalName == "" {
return nil, errors.New("hanzo/tasks/client: signalName is required")
}
if opts.TaskQueue == "" {
return nil, errors.New("hanzo/tasks/client: StartWorkflowOptions.TaskQueue is required")
}
wfType, err := workflowTypeName(workflow)
if err != nil {
return nil, err
}
req := signalWithStartWorkflowRequest{
Namespace: c.namespace,
WorkflowID: workflowID,
WorkflowType: wfType,
TaskQueue: opts.TaskQueue,
Input: workflowArgs,
CronSchedule: opts.CronSchedule,
Memo: opts.Memo,
Identity: c.identity,
Timeouts: timeouts{
WorkflowExecutionMs: opts.WorkflowExecutionTimeout.Milliseconds(),
WorkflowRunMs: opts.WorkflowRunTimeout.Milliseconds(),
WorkflowTaskMs: opts.WorkflowTaskTimeout.Milliseconds(),
},
SignalName: signalName,
SignalInput: signalArg,
}
if rp := opts.RetryPolicy; rp != nil {
req.RetryPolicy = &retryPolicy{
InitialIntervalMs: rp.InitialInterval.Milliseconds(),
BackoffCoefficient: rp.BackoffCoefficient,
MaximumIntervalMs: rp.MaximumInterval.Milliseconds(),
MaximumAttempts: rp.MaximumAttempts,
NonRetryableErrorTypes: append([]string(nil), rp.NonRetryableErrorTypes...),
}
}
var resp startWorkflowResponse
if err := c.roundTrip(ctx, opSignalWithStartWorkflow, req, &resp); err != nil {
return nil, err
}
return &workflowRunImpl{id: workflowID, runID: resp.RunID, client: c}, nil
}
// GetWorkflow implements Client. No RPC is issued; the returned handle
// polls DescribeWorkflow on Get / GetWithOptions.
func (c *clientImpl) GetWorkflow(ctx context.Context, workflowID, runID string) WorkflowRun {
// ctx is accepted to match upstream — handle construction itself
// is local and cannot fail. Subsequent RPCs from the handle honour
// the ctx passed at that call site.
_ = ctx
return &workflowRunImpl{id: workflowID, runID: runID, client: c}
}
// QueryWorkflow implements Client. Opcode 0x0067. Returns a
// converter.EncodedValue the caller decodes via Get / HasValue.
func (c *clientImpl) QueryWorkflow(
ctx context.Context,
workflowID, runID, queryType string,
args ...any,
) (converter.EncodedValue, error) {
if workflowID == "" {
return nil, errors.New("hanzo/tasks/client: workflowID is required")
}
if queryType == "" {
return nil, errors.New("hanzo/tasks/client: queryType is required")
}
req := queryWorkflowRequest{
Namespace: c.namespace,
WorkflowID: workflowID,
RunID: runID,
QueryType: queryType,
QueryArgs: args,
}
var resp queryWorkflowResponse
if err := c.roundTrip(ctx, opQueryWorkflow, req, &resp); err != nil {
return nil, err
}
return converter.NewJSONValue(resp.Result), nil
}
// workflowTypeName returns the wire name for a workflow registered with
// ExecuteWorkflow. String arguments are used as-is; function arguments
// are reflected down to their qualified Go name (pkg.FuncName) and then
// trimmed to the final identifier, matching Temporal's historical
// default and what our server's registry indexes on.
func workflowTypeName(workflow any) (string, error) {
if workflow == nil {
return "", errors.New("hanzo/tasks/client: workflow is nil")
}
if name, ok := workflow.(string); ok {
if name == "" {
return "", errors.New("hanzo/tasks/client: empty workflow type")
}
return name, nil
}
v := reflect.ValueOf(workflow)
if v.Kind() != reflect.Func {
return "", fmt.Errorf("hanzo/tasks/client: workflow must be a string or func, got %T", workflow)
}
full := runtime.FuncForPC(v.Pointer()).Name()
// Strip to short identifier: github.com/foo/pkg.Fn -> Fn.
if idx := strings.LastIndex(full, "."); idx >= 0 {
return full[idx+1:], nil
}
return full, nil
}
// Compile-time check.
var _ = json.Marshal