From 5ecc219b5d70320a366eea08b980e7a965bf47a6 Mon Sep 17 00:00:00 2001 From: Dan Revie Date: Fri, 10 Apr 2026 09:42:05 -0400 Subject: [PATCH 1/5] feat(schedule): add Temporal schedule for periodic scanning Adds Temporal Schedule API support so the OrchestratorWorkflow runs automatically on a configurable cron (default: every 6 hours). The schedule manager uses a create-or-update pattern to handle restarts gracefully. Disabled by default via SCHEDULE_ENABLED=false. New files: - pkg/schedule/schedule.go: Schedule manager with create-or-update logic - pkg/schedule/schedule_test.go: 6 unit tests covering all paths Modified: - cmd/server/main.go: Added SCHEDULE_ENABLED, SCHEDULE_CRON, SCHEDULE_ID, SCHEDULE_JITTER config; wiring with graceful failure - pkg/workflow/orchestrator/workflow.go: ScanID fallback from workflow execution ID for scheduled runs Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/server/main.go | 43 +++++- pkg/schedule/schedule.go | 115 +++++++++++++++ pkg/schedule/schedule_test.go | 202 ++++++++++++++++++++++++++ pkg/workflow/orchestrator/workflow.go | 7 + 4 files changed, 366 insertions(+), 1 deletion(-) create mode 100644 pkg/schedule/schedule.go create mode 100644 pkg/schedule/schedule_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 4e3fb27..58305ed 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -26,6 +26,7 @@ import ( invmock "github.com/block/Version-Guard/pkg/inventory/mock" "github.com/block/Version-Guard/pkg/inventory/wiz" "github.com/block/Version-Guard/pkg/policy" + "github.com/block/Version-Guard/pkg/schedule" "github.com/block/Version-Guard/pkg/snapshot" "github.com/block/Version-Guard/pkg/store/memory" "github.com/block/Version-Guard/pkg/types" @@ -67,6 +68,12 @@ type ServerCLI struct { TagEnvKeys string `help:"Comma-separated tag keys for environment" default:"environment,env" env:"TAG_ENV_KEYS"` TagBrandKeys string `help:"Comma-separated tag keys for brand/business unit" default:"brand" env:"TAG_BRAND_KEYS"` + // Schedule configuration + ScheduleEnabled bool `help:"Enable scheduled scanning" default:"false" env:"SCHEDULE_ENABLED"` + ScheduleCron string `help:"Cron expression for scan schedule" default:"0 */6 * * *" env:"SCHEDULE_CRON"` + ScheduleID string `help:"Temporal schedule ID" default:"version-guard-scan" env:"SCHEDULE_ID"` + ScheduleJitter string `help:"Schedule jitter duration" default:"5m" env:"SCHEDULE_JITTER"` + // Global flags Verbose bool `short:"v" help:"Enable verbose logging"` DryRun bool `help:"Run in dry-run mode (no Temporal workers started)"` @@ -114,6 +121,12 @@ func (s *ServerCLI) Run(_ *kong.Context) error { fmt.Printf(" Tag Keys - App: %s\n", s.TagAppKeys) fmt.Printf(" Tag Keys - Env: %s\n", s.TagEnvKeys) fmt.Printf(" Tag Keys - Brand: %s\n", s.TagBrandKeys) + if s.ScheduleEnabled { + fmt.Printf(" Schedule: enabled (cron: %s, id: %s, jitter: %s)\n", + s.ScheduleCron, s.ScheduleID, s.ScheduleJitter) + } else { + fmt.Printf(" Schedule: disabled\n") + } } if s.DryRun { @@ -327,10 +340,38 @@ func (s *ServerCLI) Run(_ *kong.Context) error { fmt.Println("⚠️ Orchestrator snapshot activity not registered (no S3 store)") } + // Create schedule (if enabled) + if s.ScheduleEnabled { + jitter, parseErr := time.ParseDuration(s.ScheduleJitter) + if parseErr != nil { + fmt.Printf("⚠️ Invalid schedule jitter %q, using default 5m: %v\n", s.ScheduleJitter, parseErr) + jitter = 5 * time.Minute + } + + scheduleMgr := schedule.NewManager(temporalClient) + schedErr := scheduleMgr.EnsureSchedule(ctx, schedule.ScheduleConfig{ + Enabled: true, + ScheduleID: s.ScheduleID, + CronExpression: s.ScheduleCron, + Jitter: jitter, + TaskQueue: s.TemporalTaskQueue, + }) + if schedErr != nil { + fmt.Printf("⚠️ Failed to create/update schedule: %v\n", schedErr) + fmt.Println(" Worker will continue — trigger scans manually") + } else { + fmt.Printf("✓ Schedule configured: %s (cron: %s, jitter: %s)\n", + s.ScheduleID, s.ScheduleCron, s.ScheduleJitter) + } + } + // Start worker fmt.Printf("\n✓ Temporal worker starting on queue: %s\n", s.TemporalTaskQueue) fmt.Println("\nVersion Guard is ready!") - fmt.Println("\n📖 To trigger a scan, use the Temporal UI or CLI:") + if s.ScheduleEnabled { + fmt.Printf(" Scans will run automatically (schedule: %s)\n", s.ScheduleCron) + } + fmt.Println("\n📖 To trigger a scan manually, use the Temporal UI or CLI:") fmt.Printf(" temporal workflow start --task-queue %s --type %s --input '{}'\n", s.TemporalTaskQueue, orchestrator.OrchestratorWorkflowType) fmt.Println("\n📖 To query findings via gRPC:") fmt.Printf(" grpcurl -plaintext localhost:%d list\n", s.GRPCPort) diff --git a/pkg/schedule/schedule.go b/pkg/schedule/schedule.go new file mode 100644 index 0000000..ebab141 --- /dev/null +++ b/pkg/schedule/schedule.go @@ -0,0 +1,115 @@ +package schedule + +import ( + "context" + "errors" + "fmt" + "time" + + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/temporal" + + "github.com/block/Version-Guard/pkg/workflow/orchestrator" +) + +// ScheduleConfig holds configuration for the Temporal schedule. +type ScheduleConfig struct { + Enabled bool + ScheduleID string + CronExpression string + Jitter time.Duration + TaskQueue string + Paused bool +} + +// ScheduleCreator abstracts the Temporal schedule client for testability. +type ScheduleCreator interface { + Create(ctx context.Context, options client.ScheduleOptions) (client.ScheduleHandle, error) + GetHandle(ctx context.Context, scheduleID string) client.ScheduleHandle +} + +// Manager handles Temporal schedule lifecycle. +type Manager struct { + scheduleClient ScheduleCreator +} + +// NewManager creates a Manager from a Temporal client. +func NewManager(c client.Client) *Manager { + return &Manager{scheduleClient: c.ScheduleClient()} +} + +// NewManagerWithClient creates a Manager with an explicit ScheduleCreator (for testing). +func NewManagerWithClient(sc ScheduleCreator) *Manager { + return &Manager{scheduleClient: sc} +} + +// EnsureSchedule creates the schedule if it doesn't exist, or updates it +// if the cron expression has changed. +func (m *Manager) EnsureSchedule(ctx context.Context, cfg ScheduleConfig) error { + if !cfg.Enabled { + return nil + } + + opts := client.ScheduleOptions{ + ID: cfg.ScheduleID, + Spec: client.ScheduleSpec{ + CronExpressions: []string{cfg.CronExpression}, + Jitter: cfg.Jitter, + }, + Action: &client.ScheduleWorkflowAction{ + Workflow: orchestrator.OrchestratorWorkflow, + Args: []interface{}{orchestrator.WorkflowInput{}}, + TaskQueue: cfg.TaskQueue, + WorkflowExecutionTimeout: 2 * time.Hour, + }, + Paused: cfg.Paused, + } + + _, err := m.scheduleClient.Create(ctx, opts) + if err == nil { + return nil + } + + // If the schedule already exists, check if we need to update it + if !isScheduleAlreadyRunning(err) { + return fmt.Errorf("failed to create schedule %q: %w", cfg.ScheduleID, err) + } + + handle := m.scheduleClient.GetHandle(ctx, cfg.ScheduleID) + desc, err := handle.Describe(ctx) + if err != nil { + return fmt.Errorf("failed to describe existing schedule %q: %w", cfg.ScheduleID, err) + } + + // Check if the cron expression or jitter has changed + existingSpec := desc.Schedule.Spec + if existingSpec == nil { + existingSpec = &client.ScheduleSpec{} + } + existingCrons := existingSpec.CronExpressions + if len(existingCrons) == 1 && existingCrons[0] == cfg.CronExpression && existingSpec.Jitter == cfg.Jitter { + fmt.Printf(" Schedule %q already configured (cron: %s)\n", cfg.ScheduleID, cfg.CronExpression) + return nil + } + + // Update the schedule with the new spec + err = handle.Update(ctx, client.ScheduleUpdateOptions{ + DoUpdate: func(input client.ScheduleUpdateInput) (*client.ScheduleUpdate, error) { + input.Description.Schedule.Spec.CronExpressions = []string{cfg.CronExpression} + input.Description.Schedule.Spec.Jitter = cfg.Jitter + return &client.ScheduleUpdate{ + Schedule: &input.Description.Schedule, + }, nil + }, + }) + if err != nil { + return fmt.Errorf("failed to update schedule %q: %w", cfg.ScheduleID, err) + } + + fmt.Printf(" Schedule %q updated (cron: %s)\n", cfg.ScheduleID, cfg.CronExpression) + return nil +} + +func isScheduleAlreadyRunning(err error) bool { + return errors.Is(err, temporal.ErrScheduleAlreadyRunning) +} diff --git a/pkg/schedule/schedule_test.go b/pkg/schedule/schedule_test.go new file mode 100644 index 0000000..cc158be --- /dev/null +++ b/pkg/schedule/schedule_test.go @@ -0,0 +1,202 @@ +package schedule + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/temporal" +) + +// mockScheduleHandle implements client.ScheduleHandle for testing. +type mockScheduleHandle struct { + id string + describeOut *client.ScheduleDescription + describeErr error + updateErr error + updateCalled bool + updateFn func(client.ScheduleUpdateOptions) +} + +func (h *mockScheduleHandle) GetID() string { return h.id } +func (h *mockScheduleHandle) Delete(_ context.Context) error { return nil } +func (h *mockScheduleHandle) Backfill(_ context.Context, _ client.ScheduleBackfillOptions) error { + return nil +} +func (h *mockScheduleHandle) Trigger(_ context.Context, _ client.ScheduleTriggerOptions) error { + return nil +} +func (h *mockScheduleHandle) Pause(_ context.Context, _ client.SchedulePauseOptions) error { + return nil +} +func (h *mockScheduleHandle) Unpause(_ context.Context, _ client.ScheduleUnpauseOptions) error { + return nil +} + +func (h *mockScheduleHandle) Describe(_ context.Context) (*client.ScheduleDescription, error) { + return h.describeOut, h.describeErr +} + +func (h *mockScheduleHandle) Update(_ context.Context, opts client.ScheduleUpdateOptions) error { + h.updateCalled = true + if h.updateFn != nil { + h.updateFn(opts) + } + return h.updateErr +} + +// mockScheduleCreator implements ScheduleCreator for testing. +type mockScheduleCreator struct { + createErr error + createHandle client.ScheduleHandle + handle *mockScheduleHandle + createOpts *client.ScheduleOptions +} + +func (c *mockScheduleCreator) Create(_ context.Context, opts client.ScheduleOptions) (client.ScheduleHandle, error) { + c.createOpts = &opts + return c.createHandle, c.createErr +} + +func (c *mockScheduleCreator) GetHandle(_ context.Context, _ string) client.ScheduleHandle { + return c.handle +} + +func TestEnsureSchedule_Disabled(t *testing.T) { + mock := &mockScheduleCreator{} + mgr := NewManagerWithClient(mock) + + err := mgr.EnsureSchedule(context.Background(), ScheduleConfig{ + Enabled: false, + }) + + require.NoError(t, err) + assert.Nil(t, mock.createOpts, "Create should not be called when disabled") +} + +func TestEnsureSchedule_CreatesNew(t *testing.T) { + mock := &mockScheduleCreator{ + createHandle: &mockScheduleHandle{id: "test-schedule"}, + } + mgr := NewManagerWithClient(mock) + + err := mgr.EnsureSchedule(context.Background(), ScheduleConfig{ + Enabled: true, + ScheduleID: "test-schedule", + CronExpression: "0 */6 * * *", + Jitter: 5 * time.Minute, + TaskQueue: "test-queue", + }) + + require.NoError(t, err) + require.NotNil(t, mock.createOpts) + assert.Equal(t, "test-schedule", mock.createOpts.ID) + assert.Equal(t, []string{"0 */6 * * *"}, mock.createOpts.Spec.CronExpressions) + assert.Equal(t, 5*time.Minute, mock.createOpts.Spec.Jitter) + action := mock.createOpts.Action.(*client.ScheduleWorkflowAction) + assert.Equal(t, "test-queue", action.TaskQueue) + assert.Equal(t, 2*time.Hour, action.WorkflowExecutionTimeout) +} + +func TestEnsureSchedule_AlreadyExists_SameCron(t *testing.T) { + handle := &mockScheduleHandle{ + id: "test-schedule", + describeOut: &client.ScheduleDescription{ + Schedule: client.Schedule{ + Spec: &client.ScheduleSpec{ + CronExpressions: []string{"0 */6 * * *"}, + Jitter: 5 * time.Minute, + }, + }, + }, + } + mock := &mockScheduleCreator{ + createErr: temporal.ErrScheduleAlreadyRunning, + handle: handle, + } + mgr := NewManagerWithClient(mock) + + err := mgr.EnsureSchedule(context.Background(), ScheduleConfig{ + Enabled: true, + ScheduleID: "test-schedule", + CronExpression: "0 */6 * * *", + Jitter: 5 * time.Minute, + TaskQueue: "test-queue", + }) + + require.NoError(t, err) + assert.False(t, handle.updateCalled, "Update should not be called when cron matches") +} + +func TestEnsureSchedule_AlreadyExists_DifferentCron(t *testing.T) { + handle := &mockScheduleHandle{ + id: "test-schedule", + describeOut: &client.ScheduleDescription{ + Schedule: client.Schedule{ + Spec: &client.ScheduleSpec{ + CronExpressions: []string{"0 */12 * * *"}, + Jitter: 5 * time.Minute, + }, + }, + }, + } + mock := &mockScheduleCreator{ + createErr: temporal.ErrScheduleAlreadyRunning, + handle: handle, + } + mgr := NewManagerWithClient(mock) + + err := mgr.EnsureSchedule(context.Background(), ScheduleConfig{ + Enabled: true, + ScheduleID: "test-schedule", + CronExpression: "0 */6 * * *", + Jitter: 5 * time.Minute, + TaskQueue: "test-queue", + }) + + require.NoError(t, err) + assert.True(t, handle.updateCalled, "Update should be called when cron differs") +} + +func TestEnsureSchedule_CreateError(t *testing.T) { + mock := &mockScheduleCreator{ + createErr: errors.New("connection refused"), + } + mgr := NewManagerWithClient(mock) + + err := mgr.EnsureSchedule(context.Background(), ScheduleConfig{ + Enabled: true, + ScheduleID: "test-schedule", + CronExpression: "0 */6 * * *", + TaskQueue: "test-queue", + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "connection refused") +} + +func TestEnsureSchedule_DescribeError(t *testing.T) { + handle := &mockScheduleHandle{ + id: "test-schedule", + describeErr: errors.New("not found"), + } + mock := &mockScheduleCreator{ + createErr: temporal.ErrScheduleAlreadyRunning, + handle: handle, + } + mgr := NewManagerWithClient(mock) + + err := mgr.EnsureSchedule(context.Background(), ScheduleConfig{ + Enabled: true, + ScheduleID: "test-schedule", + CronExpression: "0 */6 * * *", + TaskQueue: "test-queue", + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} diff --git a/pkg/workflow/orchestrator/workflow.go b/pkg/workflow/orchestrator/workflow.go index 05e093d..88e5736 100644 --- a/pkg/workflow/orchestrator/workflow.go +++ b/pkg/workflow/orchestrator/workflow.go @@ -52,6 +52,13 @@ type ResourceTypeResult struct { // Stage 3: Trigger the ActWorkflow (separate workflow) via signal func OrchestratorWorkflow(ctx workflow.Context, input WorkflowInput) (*WorkflowOutput, error) { logger := workflow.GetLogger(ctx) + + // Generate ScanID from workflow execution if not provided + // (scheduled executions pass empty ScanID) + if input.ScanID == "" { + input.ScanID = workflow.GetInfo(ctx).WorkflowExecution.ID + } + logger.Info("Starting orchestrator workflow", "scanID", input.ScanID) startTime := workflow.Now(ctx) From 0e92eecd8768e7934bc1260e2ae664114b44a0f9 Mon Sep 17 00:00:00 2001 From: Dan Revie Date: Fri, 10 Apr 2026 09:44:59 -0400 Subject: [PATCH 2/5] fix(schedule): change default cron to daily at 06:00 UTC Aligns with the design doc which specifies daily at 06:00 UTC, not every 6 hours. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/server/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 58305ed..4ee7c43 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -70,7 +70,7 @@ type ServerCLI struct { // Schedule configuration ScheduleEnabled bool `help:"Enable scheduled scanning" default:"false" env:"SCHEDULE_ENABLED"` - ScheduleCron string `help:"Cron expression for scan schedule" default:"0 */6 * * *" env:"SCHEDULE_CRON"` + ScheduleCron string `help:"Cron expression for scan schedule" default:"0 6 * * *" env:"SCHEDULE_CRON"` ScheduleID string `help:"Temporal schedule ID" default:"version-guard-scan" env:"SCHEDULE_ID"` ScheduleJitter string `help:"Schedule jitter duration" default:"5m" env:"SCHEDULE_JITTER"` From 9002fee1412a17dcb099d36668a3d9dcd6e540a7 Mon Sep 17 00:00:00 2001 From: Dan Revie Date: Mon, 13 Apr 2026 09:59:50 -0400 Subject: [PATCH 3/5] fix: address PR review feedback for schedule package - Add nil guard for Spec in DoUpdate closure to prevent panic - Propagate TaskQueue changes in schedule updates via Action assertion - Add 10s timeout on EnsureSchedule during startup - Add TestEnsureSchedule_AlreadyExists_NilSpec test case Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/server/main.go | 4 +++- pkg/schedule/schedule.go | 6 +++++ pkg/schedule/schedule_test.go | 42 +++++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 4ee7c43..b04482a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -349,7 +349,9 @@ func (s *ServerCLI) Run(_ *kong.Context) error { } scheduleMgr := schedule.NewManager(temporalClient) - schedErr := scheduleMgr.EnsureSchedule(ctx, schedule.ScheduleConfig{ + schedCtx, schedCancel := context.WithTimeout(ctx, 10*time.Second) + defer schedCancel() + schedErr := scheduleMgr.EnsureSchedule(schedCtx, schedule.ScheduleConfig{ Enabled: true, ScheduleID: s.ScheduleID, CronExpression: s.ScheduleCron, diff --git a/pkg/schedule/schedule.go b/pkg/schedule/schedule.go index ebab141..f15a14f 100644 --- a/pkg/schedule/schedule.go +++ b/pkg/schedule/schedule.go @@ -95,8 +95,14 @@ func (m *Manager) EnsureSchedule(ctx context.Context, cfg ScheduleConfig) error // Update the schedule with the new spec err = handle.Update(ctx, client.ScheduleUpdateOptions{ DoUpdate: func(input client.ScheduleUpdateInput) (*client.ScheduleUpdate, error) { + if input.Description.Schedule.Spec == nil { + input.Description.Schedule.Spec = &client.ScheduleSpec{} + } input.Description.Schedule.Spec.CronExpressions = []string{cfg.CronExpression} input.Description.Schedule.Spec.Jitter = cfg.Jitter + if action, ok := input.Description.Schedule.Action.(*client.ScheduleWorkflowAction); ok { + action.TaskQueue = cfg.TaskQueue + } return &client.ScheduleUpdate{ Schedule: &input.Description.Schedule, }, nil diff --git a/pkg/schedule/schedule_test.go b/pkg/schedule/schedule_test.go index cc158be..04ed342 100644 --- a/pkg/schedule/schedule_test.go +++ b/pkg/schedule/schedule_test.go @@ -179,6 +179,48 @@ func TestEnsureSchedule_CreateError(t *testing.T) { assert.Contains(t, err.Error(), "connection refused") } +func TestEnsureSchedule_AlreadyExists_NilSpec(t *testing.T) { + handle := &mockScheduleHandle{ + id: "test-schedule", + describeOut: &client.ScheduleDescription{ + Schedule: client.Schedule{ + Spec: nil, // nil Spec in describe output + }, + }, + } + // Capture and invoke the DoUpdate callback to verify the nil Spec guard + // inside the update path doesn't panic. + handle.updateFn = func(opts client.ScheduleUpdateOptions) { + // Simulate what the real Temporal SDK does: call DoUpdate with + // the described schedule (which has a nil Spec). + input := client.ScheduleUpdateInput{ + Description: *handle.describeOut, + } + result, err := opts.DoUpdate(input) + require.NoError(t, err, "DoUpdate should not error with nil Spec") + require.NotNil(t, result, "DoUpdate should return an update") + require.NotNil(t, result.Schedule.Spec, "Spec should be non-nil after DoUpdate sets it") + assert.Equal(t, []string{"0 */6 * * *"}, result.Schedule.Spec.CronExpressions) + assert.Equal(t, 5*time.Minute, result.Schedule.Spec.Jitter) + } + mock := &mockScheduleCreator{ + createErr: temporal.ErrScheduleAlreadyRunning, + handle: handle, + } + mgr := NewManagerWithClient(mock) + + err := mgr.EnsureSchedule(context.Background(), ScheduleConfig{ + Enabled: true, + ScheduleID: "test-schedule", + CronExpression: "0 */6 * * *", + Jitter: 5 * time.Minute, + TaskQueue: "test-queue", + }) + + require.NoError(t, err) + assert.True(t, handle.updateCalled, "Update should be called when Spec is nil") +} + func TestEnsureSchedule_DescribeError(t *testing.T) { handle := &mockScheduleHandle{ id: "test-schedule", From b3d8304091a57e6925c5a929b12e435a091c45d9 Mon Sep 17 00:00:00 2001 From: Dan Revie Date: Mon, 13 Apr 2026 11:05:29 -0400 Subject: [PATCH 4/5] fix(lint): rename stuttering types and fix struct alignment - Rename ScheduleConfig -> Config, ScheduleCreator -> Creator (revive) - Reorder mockScheduleHandle fields for alignment (govet) - Add nolint for hugeParam on mock Create (matches SDK interface) Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/server/main.go | 2 +- pkg/schedule/schedule.go | 16 +++++++------- pkg/schedule/schedule_test.go | 40 +++++++++++++++++------------------ 3 files changed, 29 insertions(+), 29 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index b04482a..7d09546 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -351,7 +351,7 @@ func (s *ServerCLI) Run(_ *kong.Context) error { scheduleMgr := schedule.NewManager(temporalClient) schedCtx, schedCancel := context.WithTimeout(ctx, 10*time.Second) defer schedCancel() - schedErr := scheduleMgr.EnsureSchedule(schedCtx, schedule.ScheduleConfig{ + schedErr := scheduleMgr.EnsureSchedule(schedCtx, schedule.Config{ Enabled: true, ScheduleID: s.ScheduleID, CronExpression: s.ScheduleCron, diff --git a/pkg/schedule/schedule.go b/pkg/schedule/schedule.go index f15a14f..0962534 100644 --- a/pkg/schedule/schedule.go +++ b/pkg/schedule/schedule.go @@ -12,8 +12,8 @@ import ( "github.com/block/Version-Guard/pkg/workflow/orchestrator" ) -// ScheduleConfig holds configuration for the Temporal schedule. -type ScheduleConfig struct { +// Config holds configuration for the Temporal schedule. +type Config struct { Enabled bool ScheduleID string CronExpression string @@ -22,15 +22,15 @@ type ScheduleConfig struct { Paused bool } -// ScheduleCreator abstracts the Temporal schedule client for testability. -type ScheduleCreator interface { +// Creator abstracts the Temporal schedule client for testability. +type Creator interface { Create(ctx context.Context, options client.ScheduleOptions) (client.ScheduleHandle, error) GetHandle(ctx context.Context, scheduleID string) client.ScheduleHandle } // Manager handles Temporal schedule lifecycle. type Manager struct { - scheduleClient ScheduleCreator + scheduleClient Creator } // NewManager creates a Manager from a Temporal client. @@ -38,14 +38,14 @@ func NewManager(c client.Client) *Manager { return &Manager{scheduleClient: c.ScheduleClient()} } -// NewManagerWithClient creates a Manager with an explicit ScheduleCreator (for testing). -func NewManagerWithClient(sc ScheduleCreator) *Manager { +// NewManagerWithClient creates a Manager with an explicit Creator (for testing). +func NewManagerWithClient(sc Creator) *Manager { return &Manager{scheduleClient: sc} } // EnsureSchedule creates the schedule if it doesn't exist, or updates it // if the cron expression has changed. -func (m *Manager) EnsureSchedule(ctx context.Context, cfg ScheduleConfig) error { +func (m *Manager) EnsureSchedule(ctx context.Context, cfg Config) error { if !cfg.Enabled { return nil } diff --git a/pkg/schedule/schedule_test.go b/pkg/schedule/schedule_test.go index 04ed342..96c350b 100644 --- a/pkg/schedule/schedule_test.go +++ b/pkg/schedule/schedule_test.go @@ -14,12 +14,12 @@ import ( // mockScheduleHandle implements client.ScheduleHandle for testing. type mockScheduleHandle struct { - id string describeOut *client.ScheduleDescription + updateFn func(client.ScheduleUpdateOptions) + id string describeErr error updateErr error updateCalled bool - updateFn func(client.ScheduleUpdateOptions) } func (h *mockScheduleHandle) GetID() string { return h.id } @@ -49,28 +49,28 @@ func (h *mockScheduleHandle) Update(_ context.Context, opts client.ScheduleUpdat return h.updateErr } -// mockScheduleCreator implements ScheduleCreator for testing. -type mockScheduleCreator struct { +// mockCreator implements Creator for testing. +type mockCreator struct { createErr error createHandle client.ScheduleHandle handle *mockScheduleHandle createOpts *client.ScheduleOptions } -func (c *mockScheduleCreator) Create(_ context.Context, opts client.ScheduleOptions) (client.ScheduleHandle, error) { +func (c *mockCreator) Create(_ context.Context, opts client.ScheduleOptions) (client.ScheduleHandle, error) { //nolint:gocritic // matches SDK interface c.createOpts = &opts return c.createHandle, c.createErr } -func (c *mockScheduleCreator) GetHandle(_ context.Context, _ string) client.ScheduleHandle { +func (c *mockCreator) GetHandle(_ context.Context, _ string) client.ScheduleHandle { return c.handle } func TestEnsureSchedule_Disabled(t *testing.T) { - mock := &mockScheduleCreator{} + mock := &mockCreator{} mgr := NewManagerWithClient(mock) - err := mgr.EnsureSchedule(context.Background(), ScheduleConfig{ + err := mgr.EnsureSchedule(context.Background(), Config{ Enabled: false, }) @@ -79,12 +79,12 @@ func TestEnsureSchedule_Disabled(t *testing.T) { } func TestEnsureSchedule_CreatesNew(t *testing.T) { - mock := &mockScheduleCreator{ + mock := &mockCreator{ createHandle: &mockScheduleHandle{id: "test-schedule"}, } mgr := NewManagerWithClient(mock) - err := mgr.EnsureSchedule(context.Background(), ScheduleConfig{ + err := mgr.EnsureSchedule(context.Background(), Config{ Enabled: true, ScheduleID: "test-schedule", CronExpression: "0 */6 * * *", @@ -114,13 +114,13 @@ func TestEnsureSchedule_AlreadyExists_SameCron(t *testing.T) { }, }, } - mock := &mockScheduleCreator{ + mock := &mockCreator{ createErr: temporal.ErrScheduleAlreadyRunning, handle: handle, } mgr := NewManagerWithClient(mock) - err := mgr.EnsureSchedule(context.Background(), ScheduleConfig{ + err := mgr.EnsureSchedule(context.Background(), Config{ Enabled: true, ScheduleID: "test-schedule", CronExpression: "0 */6 * * *", @@ -144,13 +144,13 @@ func TestEnsureSchedule_AlreadyExists_DifferentCron(t *testing.T) { }, }, } - mock := &mockScheduleCreator{ + mock := &mockCreator{ createErr: temporal.ErrScheduleAlreadyRunning, handle: handle, } mgr := NewManagerWithClient(mock) - err := mgr.EnsureSchedule(context.Background(), ScheduleConfig{ + err := mgr.EnsureSchedule(context.Background(), Config{ Enabled: true, ScheduleID: "test-schedule", CronExpression: "0 */6 * * *", @@ -163,12 +163,12 @@ func TestEnsureSchedule_AlreadyExists_DifferentCron(t *testing.T) { } func TestEnsureSchedule_CreateError(t *testing.T) { - mock := &mockScheduleCreator{ + mock := &mockCreator{ createErr: errors.New("connection refused"), } mgr := NewManagerWithClient(mock) - err := mgr.EnsureSchedule(context.Background(), ScheduleConfig{ + err := mgr.EnsureSchedule(context.Background(), Config{ Enabled: true, ScheduleID: "test-schedule", CronExpression: "0 */6 * * *", @@ -203,13 +203,13 @@ func TestEnsureSchedule_AlreadyExists_NilSpec(t *testing.T) { assert.Equal(t, []string{"0 */6 * * *"}, result.Schedule.Spec.CronExpressions) assert.Equal(t, 5*time.Minute, result.Schedule.Spec.Jitter) } - mock := &mockScheduleCreator{ + mock := &mockCreator{ createErr: temporal.ErrScheduleAlreadyRunning, handle: handle, } mgr := NewManagerWithClient(mock) - err := mgr.EnsureSchedule(context.Background(), ScheduleConfig{ + err := mgr.EnsureSchedule(context.Background(), Config{ Enabled: true, ScheduleID: "test-schedule", CronExpression: "0 */6 * * *", @@ -226,13 +226,13 @@ func TestEnsureSchedule_DescribeError(t *testing.T) { id: "test-schedule", describeErr: errors.New("not found"), } - mock := &mockScheduleCreator{ + mock := &mockCreator{ createErr: temporal.ErrScheduleAlreadyRunning, handle: handle, } mgr := NewManagerWithClient(mock) - err := mgr.EnsureSchedule(context.Background(), ScheduleConfig{ + err := mgr.EnsureSchedule(context.Background(), Config{ Enabled: true, ScheduleID: "test-schedule", CronExpression: "0 */6 * * *", From 011e66af407759257757350ab85cf4cd0c88d588 Mon Sep 17 00:00:00 2001 From: Dan Revie Date: Mon, 13 Apr 2026 11:17:24 -0400 Subject: [PATCH 5/5] fix(lint): correct struct field alignment for Config and mockScheduleHandle Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/schedule/schedule.go | 4 ++-- pkg/schedule/schedule_test.go | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/schedule/schedule.go b/pkg/schedule/schedule.go index 0962534..98d4639 100644 --- a/pkg/schedule/schedule.go +++ b/pkg/schedule/schedule.go @@ -14,11 +14,11 @@ import ( // Config holds configuration for the Temporal schedule. type Config struct { - Enabled bool ScheduleID string CronExpression string - Jitter time.Duration TaskQueue string + Jitter time.Duration + Enabled bool Paused bool } diff --git a/pkg/schedule/schedule_test.go b/pkg/schedule/schedule_test.go index 96c350b..93b3ba6 100644 --- a/pkg/schedule/schedule_test.go +++ b/pkg/schedule/schedule_test.go @@ -14,11 +14,11 @@ import ( // mockScheduleHandle implements client.ScheduleHandle for testing. type mockScheduleHandle struct { - describeOut *client.ScheduleDescription - updateFn func(client.ScheduleUpdateOptions) - id string describeErr error updateErr error + updateFn func(client.ScheduleUpdateOptions) + describeOut *client.ScheduleDescription + id string updateCalled bool }