mirror of
https://github.com/hanzoai/tasks.git
synced 2026-10-04 02:04:03 +00:00
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]>
337 lines
10 KiB
Go
337 lines
10 KiB
Go
package client
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"time"
|
|
)
|
|
|
|
// CreateScheduleOptions configures CreateSchedule. Mirrors schema/tasks.zap
|
|
// Schedule / ScheduleSpec / ScheduleAction on the v1 JSON wire.
|
|
type CreateScheduleOptions struct {
|
|
ID string
|
|
|
|
// Spec: use either Cron expressions OR a single Interval.
|
|
CronExpressions []string
|
|
Interval time.Duration
|
|
StartTime time.Time
|
|
EndTime time.Time
|
|
Jitter time.Duration
|
|
Timezone string
|
|
|
|
// Action: the workflow to start on each tick.
|
|
WorkflowID string
|
|
WorkflowType string
|
|
TaskQueue string
|
|
Input []any
|
|
|
|
Paused bool
|
|
}
|
|
|
|
// Schedule mirrors schema/tasks.zap Schedule for the v1 wire.
|
|
type Schedule struct {
|
|
ID string `json:"id"`
|
|
Spec ScheduleSpec `json:"spec"`
|
|
Action ScheduleAction `json:"action"`
|
|
Paused bool `json:"paused,omitempty"`
|
|
}
|
|
|
|
// ScheduleSpec mirrors schema/tasks.zap ScheduleSpec on the wire. Durations
|
|
// are exposed as Go time.Duration on the Go surface and as milliseconds
|
|
// on the wire.
|
|
type ScheduleSpec struct {
|
|
Cron []string `json:"-"`
|
|
Interval time.Duration `json:"-"`
|
|
StartTime time.Time `json:"-"`
|
|
EndTime time.Time `json:"-"`
|
|
Jitter time.Duration `json:"-"`
|
|
Timezone string `json:"-"`
|
|
}
|
|
|
|
type scheduleSpecWire struct {
|
|
Cron []string `json:"cron,omitempty"`
|
|
IntervalMs int64 `json:"interval_ms,omitempty"`
|
|
StartTimeMs int64 `json:"start_time_ms,omitempty"`
|
|
EndTimeMs int64 `json:"end_time_ms,omitempty"`
|
|
JitterMs int64 `json:"jitter_ms,omitempty"`
|
|
Timezone string `json:"timezone,omitempty"`
|
|
}
|
|
|
|
// MarshalJSON implements the ms-on-the-wire contract.
|
|
func (s ScheduleSpec) MarshalJSON() ([]byte, error) {
|
|
w := scheduleSpecWire{
|
|
Cron: append([]string(nil), s.Cron...),
|
|
IntervalMs: s.Interval.Milliseconds(),
|
|
JitterMs: s.Jitter.Milliseconds(),
|
|
Timezone: s.Timezone,
|
|
}
|
|
if !s.StartTime.IsZero() {
|
|
w.StartTimeMs = s.StartTime.UnixMilli()
|
|
}
|
|
if !s.EndTime.IsZero() {
|
|
w.EndTimeMs = s.EndTime.UnixMilli()
|
|
}
|
|
return jsonMarshal(w)
|
|
}
|
|
|
|
// UnmarshalJSON implements the ms-on-the-wire contract.
|
|
func (s *ScheduleSpec) UnmarshalJSON(b []byte) error {
|
|
var w scheduleSpecWire
|
|
if err := jsonUnmarshal(b, &w); err != nil {
|
|
return err
|
|
}
|
|
s.Cron = append([]string(nil), w.Cron...)
|
|
s.Interval = time.Duration(w.IntervalMs) * time.Millisecond
|
|
s.Jitter = time.Duration(w.JitterMs) * time.Millisecond
|
|
s.Timezone = w.Timezone
|
|
if w.StartTimeMs != 0 {
|
|
s.StartTime = time.UnixMilli(w.StartTimeMs)
|
|
}
|
|
if w.EndTimeMs != 0 {
|
|
s.EndTime = time.UnixMilli(w.EndTimeMs)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ScheduleAction mirrors schema/tasks.zap ScheduleAction.
|
|
type ScheduleAction struct {
|
|
WorkflowID string `json:"workflow_id,omitempty"`
|
|
WorkflowType string `json:"workflow_type"`
|
|
TaskQueue string `json:"task_queue"`
|
|
Input []any `json:"input,omitempty"`
|
|
}
|
|
|
|
type createScheduleRequest struct {
|
|
Namespace string `json:"namespace"`
|
|
ScheduleID string `json:"schedule_id"`
|
|
Schedule Schedule `json:"schedule"`
|
|
}
|
|
|
|
type listSchedulesRequest struct {
|
|
Namespace string `json:"namespace"`
|
|
PageSize int32 `json:"page_size,omitempty"`
|
|
NextPageToken []byte `json:"next_page_token,omitempty"`
|
|
}
|
|
|
|
// ListSchedulesResponse is returned by ListSchedules.
|
|
type ListSchedulesResponse struct {
|
|
Schedules []Schedule `json:"schedules"`
|
|
NextPageToken []byte `json:"next_page_token,omitempty"`
|
|
}
|
|
|
|
type deleteScheduleRequest struct {
|
|
Namespace string `json:"namespace"`
|
|
ScheduleID string `json:"schedule_id"`
|
|
}
|
|
|
|
type pauseScheduleRequest struct {
|
|
Namespace string `json:"namespace"`
|
|
ScheduleID string `json:"schedule_id"`
|
|
Paused bool `json:"paused"`
|
|
}
|
|
|
|
// UpdateScheduleOptions carries the new schedule definition. Replaces
|
|
// every field of the existing schedule — partial updates are not
|
|
// supported by the v1 wire (mirroring upstream UpdateSchedule).
|
|
type UpdateScheduleOptions struct {
|
|
ID string
|
|
Schedule Schedule
|
|
}
|
|
|
|
type updateScheduleRequest struct {
|
|
Namespace string `json:"namespace"`
|
|
ScheduleID string `json:"schedule_id"`
|
|
Schedule Schedule `json:"schedule"`
|
|
}
|
|
|
|
type triggerScheduleRequest struct {
|
|
Namespace string `json:"namespace"`
|
|
ScheduleID string `json:"schedule_id"`
|
|
OverlapPolicy string `json:"overlap_policy,omitempty"`
|
|
}
|
|
|
|
type describeScheduleRequest struct {
|
|
Namespace string `json:"namespace"`
|
|
ScheduleID string `json:"schedule_id"`
|
|
}
|
|
|
|
// DescribeScheduleResponse is the v1 wire response for DescribeSchedule.
|
|
type DescribeScheduleResponse struct {
|
|
Schedule Schedule `json:"schedule"`
|
|
Info ScheduleInfo `json:"info"`
|
|
}
|
|
|
|
// ScheduleInfo carries runtime state for a schedule.
|
|
type ScheduleInfo struct {
|
|
ActionCount int64 `json:"action_count"`
|
|
MissedCatchupCount int64 `json:"missed_catchup_count"`
|
|
OverlapSkipped int64 `json:"overlap_skipped"`
|
|
BufferDropped int64 `json:"buffer_dropped"`
|
|
BufferSize int64 `json:"buffer_size"`
|
|
RunningWorkflows []ScheduleListEntry `json:"running_workflows,omitempty"`
|
|
}
|
|
|
|
// ScheduleListEntry identifies one running workflow started by a schedule.
|
|
type ScheduleListEntry struct {
|
|
WorkflowID string `json:"workflow_id"`
|
|
RunID string `json:"run_id"`
|
|
}
|
|
|
|
// CreateSchedule implements Client.
|
|
func (c *clientImpl) CreateSchedule(ctx context.Context, opts CreateScheduleOptions) error {
|
|
if opts.ID == "" {
|
|
return errors.New("hanzo/tasks/client: schedule ID is required")
|
|
}
|
|
if opts.WorkflowType == "" {
|
|
return errors.New("hanzo/tasks/client: schedule action WorkflowType is required")
|
|
}
|
|
if opts.TaskQueue == "" {
|
|
return errors.New("hanzo/tasks/client: schedule action TaskQueue is required")
|
|
}
|
|
if len(opts.CronExpressions) == 0 && opts.Interval == 0 {
|
|
return errors.New("hanzo/tasks/client: schedule spec needs CronExpressions or Interval")
|
|
}
|
|
|
|
req := createScheduleRequest{
|
|
Namespace: c.namespace,
|
|
ScheduleID: opts.ID,
|
|
Schedule: Schedule{
|
|
ID: opts.ID,
|
|
Spec: ScheduleSpec{
|
|
Cron: opts.CronExpressions,
|
|
Interval: opts.Interval,
|
|
StartTime: opts.StartTime,
|
|
EndTime: opts.EndTime,
|
|
Jitter: opts.Jitter,
|
|
Timezone: opts.Timezone,
|
|
},
|
|
Action: ScheduleAction{
|
|
WorkflowID: opts.WorkflowID,
|
|
WorkflowType: opts.WorkflowType,
|
|
TaskQueue: opts.TaskQueue,
|
|
Input: opts.Input,
|
|
},
|
|
Paused: opts.Paused,
|
|
},
|
|
}
|
|
return c.roundTrip(ctx, opCreateSchedule, req, nil)
|
|
}
|
|
|
|
// ListSchedules implements Client.
|
|
func (c *clientImpl) ListSchedules(ctx context.Context, pageSize int32, nextPageToken []byte) (*ListSchedulesResponse, error) {
|
|
var resp ListSchedulesResponse
|
|
req := listSchedulesRequest{
|
|
Namespace: c.namespace,
|
|
PageSize: pageSize,
|
|
NextPageToken: nextPageToken,
|
|
}
|
|
if err := c.roundTrip(ctx, opListSchedules, req, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
return &resp, nil
|
|
}
|
|
|
|
// DeleteSchedule implements Client.
|
|
func (c *clientImpl) DeleteSchedule(ctx context.Context, scheduleID string) error {
|
|
if scheduleID == "" {
|
|
return errors.New("hanzo/tasks/client: schedule ID is required")
|
|
}
|
|
return c.roundTrip(ctx, opDeleteSchedule, deleteScheduleRequest{
|
|
Namespace: c.namespace,
|
|
ScheduleID: scheduleID,
|
|
}, nil)
|
|
}
|
|
|
|
// PauseSchedule implements Client.
|
|
func (c *clientImpl) PauseSchedule(ctx context.Context, scheduleID string, paused bool) error {
|
|
if scheduleID == "" {
|
|
return errors.New("hanzo/tasks/client: schedule ID is required")
|
|
}
|
|
return c.roundTrip(ctx, opPauseSchedule, pauseScheduleRequest{
|
|
Namespace: c.namespace,
|
|
ScheduleID: scheduleID,
|
|
Paused: paused,
|
|
}, nil)
|
|
}
|
|
|
|
// UnpauseSchedule resumes a paused schedule. Sugar over PauseSchedule.
|
|
func (c *clientImpl) UnpauseSchedule(ctx context.Context, scheduleID string) error {
|
|
return c.PauseSchedule(ctx, scheduleID, false)
|
|
}
|
|
|
|
// UpdateSchedule replaces the schedule definition for scheduleID. The
|
|
// schedule's spec / action / paused flag are taken from opts.Schedule;
|
|
// every field is replaced (partial updates are not supported on the v1
|
|
// wire — same shape as upstream UpdateSchedule).
|
|
func (c *clientImpl) UpdateSchedule(ctx context.Context, opts UpdateScheduleOptions) error {
|
|
if opts.ID == "" {
|
|
return errors.New("hanzo/tasks/client: schedule ID is required")
|
|
}
|
|
return c.roundTrip(ctx, opUpdateSchedule, updateScheduleRequest{
|
|
Namespace: c.namespace,
|
|
ScheduleID: opts.ID,
|
|
Schedule: opts.Schedule,
|
|
}, nil)
|
|
}
|
|
|
|
// TriggerSchedule fires the schedule once immediately, ignoring its
|
|
// spec. overlapPolicy is one of "skip" / "allow" / "buffer-one"; an
|
|
// empty string defers to the schedule's configured policy.
|
|
func (c *clientImpl) TriggerSchedule(ctx context.Context, scheduleID, overlapPolicy string) error {
|
|
if scheduleID == "" {
|
|
return errors.New("hanzo/tasks/client: schedule ID is required")
|
|
}
|
|
return c.roundTrip(ctx, opTriggerSchedule, triggerScheduleRequest{
|
|
Namespace: c.namespace,
|
|
ScheduleID: scheduleID,
|
|
OverlapPolicy: overlapPolicy,
|
|
}, nil)
|
|
}
|
|
|
|
// DescribeSchedule returns the current definition + runtime info for a
|
|
// schedule.
|
|
func (c *clientImpl) DescribeSchedule(ctx context.Context, scheduleID string) (*DescribeScheduleResponse, error) {
|
|
if scheduleID == "" {
|
|
return nil, errors.New("hanzo/tasks/client: schedule ID is required")
|
|
}
|
|
var resp DescribeScheduleResponse
|
|
if err := c.roundTrip(ctx, opDescribeSchedule, describeScheduleRequest{
|
|
Namespace: c.namespace,
|
|
ScheduleID: scheduleID,
|
|
}, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
return &resp, nil
|
|
}
|
|
|
|
// Health implements Client.
|
|
func (c *clientImpl) Health(ctx context.Context) (service, status string, err error) {
|
|
resp, herr := c.CheckHealth(ctx, nil)
|
|
if herr != nil {
|
|
return "", "", herr
|
|
}
|
|
return resp.Service, resp.Status, nil
|
|
}
|
|
|
|
// CheckHealth implements Client. Backed by opcode 0x0090. A nil req is
|
|
// treated as an empty request (the v1 wire reserves the request body
|
|
// for future filters).
|
|
func (c *clientImpl) CheckHealth(ctx context.Context, req *CheckHealthRequest) (*CheckHealthResponse, error) {
|
|
if req == nil {
|
|
req = &CheckHealthRequest{}
|
|
}
|
|
_ = req // empty in v1 — present so round-trip uses the canonical shape
|
|
var resp CheckHealthResponse
|
|
if err := c.roundTrip(ctx, opHealth, struct{}{}, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
return &resp, nil
|
|
}
|
|
|
|
// Internal helpers to keep json usage consistent and testable. These
|
|
// exist so every struct with a custom ms-on-the-wire marshaller uses
|
|
// the same encoding path.
|
|
func jsonMarshal(v any) ([]byte, error) { return json.Marshal(v) }
|
|
func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
|