Files
zeekay e0171b4015 feat(sdk/workflow): native Context/Future/Selector/Channel surface
Ports the workflow-code-facing API that base/plugins/tasks and
commerce/infra rely on today, with zero go.temporal.io or grpc
imports. Wire shape mirrors Temporal exactly so call-site migration
is a pure import-path swap.

Package layout (pkg/sdk/workflow):
  env.go       — CoroutineEnv interface, the one seam the worker
                 (pkg/sdk/worker, separately) must satisfy; Info,
                 SelectCase, CancelFunc. NewContextFromEnv is the
                 single entry point user ctx flows through.
  context.go   — Context interface (Deadline/Done/Err/Value), ctx
                 chain with WithCancel / WithValue / WithActivityOptions;
                 GetLogger, Now, Sleep, GetInfo.
  future.go    — Future + settleable impl. NewFuture / Settle split
                 so both the worker and the in-memory stub drive
                 completion through the same concrete. EncodePayload
                 is the Phase-1 JSON codec; Phase 2 swaps for ZAP.
  channel.go   — ReceiveChannel / Channel; unbuffered + buffered
                 chanImpl backing both user channels and signal
                 queues. Send/Receive/ReceiveAsync/Close.
  selector.go  — Selector with AddFuture / AddReceive / AddDefault;
                 Select delegates to env.Select and dispatches to
                 the matched callback.
  timer.go     — NewTimer schedules a durable timer Future.
  signal.go    — GetSignalChannel returns shared-by-name signal queues.
  activity.go  — ActivityOptions mirror of Temporal shape;
                 WithActivityOptions attaches per-call opts;
                 ExecuteActivity reads opts from ctx and dispatches.
  reflect.go   — Sole reflect use site (activity name resolution).
  stub.go      — StubEnv: synchronous, in-memory CoroutineEnv for
                 unit-testing workflow code without a worker.
                 OnActivity(...).Return(...), SignalAsync, AdvanceClock,
                 Cancel. Used throughout workflow_test.go.
  workflow_test.go — 27 tests exercising Context, cancel scopes,
                 Now/Sleep, timers, activities (success/error/nil),
                 signal channels, user channels, Selector (future/
                 receive/default), and an end-to-end AgentTaskWorkflow-
                 shaped test that mirrors base/plugins/tasks.

Phase 1 determinism caveats (documented in env.go):
  * No event-sourced replay yet — crashes restart from the top with
    the original input. Same idempotency contract as Temporal.
  * Payloads encoded as JSON, not the canonical ZAP codec.
  * time.Now / rand / goroutines inside workflow code remain banned;
    CI linter comes in a follow-up.

go vet clean. 27 tests pass including -race.
go list -deps reports zero go.temporal.io or google.golang.org/grpc.
2026-04-23 10:34:17 -07:00

103 lines
3.7 KiB
Go

package workflow
import (
"errors"
"time"
"github.com/hanzoai/tasks/pkg/sdk/temporal"
)
// ActivityOptions configures a single activity invocation. Attached
// to a Context via WithActivityOptions; every ExecuteActivity call
// below reads the attached options at dispatch time.
//
// Shape matches go.temporal.io/sdk/workflow.ActivityOptions so
// existing base/commerce call sites compile unchanged after the
// import swap.
type ActivityOptions struct {
// TaskQueue routes the activity to a specific worker pool.
// Empty means "the workflow's own task queue" (the common case).
TaskQueue string
// ScheduleToStartTimeout caps the wait between enqueue and
// worker pick-up. 0 = unlimited.
ScheduleToStartTimeout time.Duration
// StartToCloseTimeout caps a single activity run. Exceeding it
// forces a retry per RetryPolicy. 0 = unlimited (NOT
// recommended; configure something reasonable).
StartToCloseTimeout time.Duration
// ScheduleToCloseTimeout is the end-to-end budget including
// retries. 0 means "derive from StartToCloseTimeout *
// MaximumAttempts".
ScheduleToCloseTimeout time.Duration
// HeartbeatTimeout is the max interval between heartbeats for
// a long-running activity. Exceeding it aborts the attempt
// and triggers retry. 0 = no heartbeat required.
HeartbeatTimeout time.Duration
// RetryPolicy controls attempt count and backoff. nil = the
// SDK default (unlimited attempts, exponential backoff starting
// at 1s, capped at 100s). Pass &temporal.RetryPolicy{MaximumAttempts: 1}
// to disable retries entirely.
RetryPolicy *temporal.RetryPolicy
// WaitForCancellation, when true, makes cancellation wait for
// the activity to acknowledge before returning. Default false:
// cancellation unblocks the caller immediately and the
// activity is expected to observe ctx.Done and exit.
WaitForCancellation bool
}
// WithActivityOptions returns a Context carrying opts. The ctx
// returned is what ExecuteActivity(ctx, …) consumes when it
// dispatches the activity.
func WithActivityOptions(parent Context, opts ActivityOptions) Context {
if parent == nil {
panic("workflow.WithActivityOptions: nil context")
}
cp := opts // copy so later caller mutation doesn't leak
return &ctxImpl{parent: parent, opts: &cp}
}
// ExecuteActivity dispatches an activity for execution and returns
// the Future that settles with its (result, error). activity may
// be a function value or a registered name (string). args are the
// activity's input parameters; they will be JSON-encoded for the
// wire.
//
// The activity's attached ActivityOptions are read from ctx. If no
// options were attached, the SDK default is used (unlimited retry,
// no timeouts — configure something).
//
// Callers MUST NOT spawn goroutines that call ExecuteActivity; in
// Phase 1 the workflow is a single coroutine and parallel dispatch
// is done by calling ExecuteActivity N times in a loop and then
// Get-ing the Futures (or selecting on them).
func ExecuteActivity(ctx Context, activity any, args ...any) Future {
if ctx == nil {
f := NewFuture()
f.Settle(nil, errNilContext)
return f
}
if activity == nil {
f := NewFuture()
f.Settle(nil, temporal.NewError("nil activity", temporal.CodeApplication, true))
return f
}
opts := ctx.activityOptions()
return ctx.env().ExecuteActivity(opts, activity, args)
}
// errNilContext is returned when user code passes a nil Context
// into a helper. We surface it as a non-retryable Error so retry
// policies don't try to recover from a programming bug.
var errNilContext = &temporal.Error{
Message: "workflow.Context is nil",
Code: temporal.CodeApplication,
NonRetryable: true,
Cause: errors.New("nil context"),
}