diff --git a/internal/temporalcli/commands.gen.go b/internal/temporalcli/commands.gen.go index 03424647a..edc16796a 100644 --- a/internal/temporalcli/commands.gen.go +++ b/internal/temporalcli/commands.gen.go @@ -2103,6 +2103,7 @@ func NewTemporalScheduleCommand(cctx *CommandContext, parent *TemporalCommand) * s.Command.AddCommand(&NewTemporalScheduleDeleteCommand(cctx, &s).Command) s.Command.AddCommand(&NewTemporalScheduleDescribeCommand(cctx, &s).Command) s.Command.AddCommand(&NewTemporalScheduleListCommand(cctx, &s).Command) + s.Command.AddCommand(&NewTemporalScheduleListMatchingTimesCommand(cctx, &s).Command) s.Command.AddCommand(&NewTemporalScheduleToggleCommand(cctx, &s).Command) s.Command.AddCommand(&NewTemporalScheduleTriggerCommand(cctx, &s).Command) s.Command.AddCommand(&NewTemporalScheduleUpdateCommand(cctx, &s).Command) @@ -2270,6 +2271,39 @@ func NewTemporalScheduleListCommand(cctx *CommandContext, parent *TemporalSchedu return &s } +type TemporalScheduleListMatchingTimesCommand struct { + Parent *TemporalScheduleCommand + Command cobra.Command + ScheduleIdOptions + StartTime cliext.FlagTimestamp + EndTime cliext.FlagTimestamp +} + +func NewTemporalScheduleListMatchingTimesCommand(cctx *CommandContext, parent *TemporalScheduleCommand) *TemporalScheduleListMatchingTimesCommand { + var s TemporalScheduleListMatchingTimesCommand + s.Parent = parent + s.Command.DisableFlagsInUseLine = true + s.Command.Use = "list-matching-times [flags]" + s.Command.Short = "List matching times for a Schedule (Experimental)" + if hasHighlighting { + s.Command.Long = "\nNote: This is an experimental feature and may change in the future.\n\nList the times a Schedule would fire within a given time range.\nUse this command to preview when a Schedule will trigger Workflow\nExecutions without actually running them.\n\nFor example:\n\n\x1b[1m temporal schedule list-matching-times \\\n --schedule-id \"YourScheduleId\" \\\n --start-time \"2024-01-01T00:00:00Z\" \\\n --end-time \"2024-01-31T23:59:59Z\"\x1b[0m" + } else { + s.Command.Long = "\nNote: This is an experimental feature and may change in the future.\n\nList the times a Schedule would fire within a given time range.\nUse this command to preview when a Schedule will trigger Workflow\nExecutions without actually running them.\n\nFor example:\n\n```\n temporal schedule list-matching-times \\\n --schedule-id \"YourScheduleId\" \\\n --start-time \"2024-01-01T00:00:00Z\" \\\n --end-time \"2024-01-31T23:59:59Z\"\n```" + } + s.Command.Args = cobra.NoArgs + s.Command.Flags().Var(&s.StartTime, "start-time", "Start of time range to list matching times. Required.") + _ = cobra.MarkFlagRequired(s.Command.Flags(), "start-time") + s.Command.Flags().Var(&s.EndTime, "end-time", "End of time range to list matching times. Required.") + _ = cobra.MarkFlagRequired(s.Command.Flags(), "end-time") + s.ScheduleIdOptions.BuildFlags(s.Command.Flags()) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } + } + return &s +} + type TemporalScheduleToggleCommand struct { Parent *TemporalScheduleCommand Command cobra.Command diff --git a/internal/temporalcli/commands.schedule.go b/internal/temporalcli/commands.schedule.go index 5deadb8ec..7d95560a5 100644 --- a/internal/temporalcli/commands.schedule.go +++ b/internal/temporalcli/commands.schedule.go @@ -1,6 +1,7 @@ package temporalcli import ( + "encoding/json" "errors" "fmt" "regexp" @@ -11,10 +12,12 @@ import ( "github.com/temporalio/cli/cliext" "github.com/temporalio/cli/internal/printer" "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/types/known/timestamppb" commonpb "go.temporal.io/api/common/v1" enumspb "go.temporal.io/api/enums/v1" schedpb "go.temporal.io/api/schedule/v1" + "go.temporal.io/api/temporalproto" "go.temporal.io/api/workflowservice/v1" "go.temporal.io/sdk/client" ) @@ -47,8 +50,8 @@ type printableSchedule struct { LastUpdateAt time.Time `cli:",cardOmitEmpty"` // describe only ActionCounts *actionCounts `cli:",cardOmitEmpty"` // describe only // SearchAttributes, Memo - SearchAttributes *commonpb.SearchAttributes `cli:",cardOmitEmpty"` - Memo *commonpb.Memo `cli:",cardOmitEmpty"` + SearchAttributes map[string]interface{} `cli:",cardOmitEmpty"` + Memo *commonpb.Memo `cli:",cardOmitEmpty"` } type actionCounts struct { @@ -68,7 +71,7 @@ func describeResultToPrintable(id string, desc *client.ScheduleDescription) *pri // ID, SearchAttributes, Memo out := &printableSchedule{ ScheduleId: id, - SearchAttributes: desc.SearchAttributes, + SearchAttributes: searchAttributesToMap(desc.SearchAttributes), Memo: desc.Memo, } // Schedule.Action @@ -113,7 +116,7 @@ func listEntryToPrintable(ent *client.ScheduleListEntry) *printableSchedule { Paused: ent.Paused, Notes: ent.Note, Action: struct{ Workflow string }{Workflow: ent.WorkflowType.Name}, - SearchAttributes: ent.SearchAttributes, + SearchAttributes: searchAttributesToMap(ent.SearchAttributes), Memo: ent.Memo, } specToPrintable(out, ent.Spec) @@ -606,3 +609,52 @@ func formatDuration(d time.Duration) string { s = strings.TrimSpace(s) return s } + +func (c *TemporalScheduleListMatchingTimesCommand) run(cctx *CommandContext, args []string) error { + cl, err := dialClient(cctx, &c.Parent.ClientOptions) + if err != nil { + return err + } + defer cl.Close() + + res, err := cl.WorkflowService().ListScheduleMatchingTimes(cctx, &workflowservice.ListScheduleMatchingTimesRequest{ + Namespace: c.Parent.Namespace, + ScheduleId: c.ScheduleId, + StartTime: timestamppb.New(c.StartTime.Time()), + EndTime: timestamppb.New(c.EndTime.Time()), + }) + if err != nil { + return err + } + + cctx.Printer.StartList() + defer cctx.Printer.EndList() + + type matchingTime struct { + Time string `json:"time"` + } + + for _, t := range res.StartTime { + cctx.Printer.PrintStructured(matchingTime{Time: t.AsTime().String()}, printer.StructuredOptions{}) + } + + return nil +} + +func searchAttributesToMap(sa *commonpb.SearchAttributes) map[string]interface{} { + // Step 1 — handle nil + if sa == nil { + return nil + } + // Step 2 — marshal to JSON bytes using proto marshaler + b, err := temporalproto.CustomJSONMarshalOptions{}.Marshal(sa) + if err != nil { + return nil + } + // Step 3 — unmarshal bytes into plain map + var m map[string]interface{} + if err := json.Unmarshal(b, &m); err != nil { + return nil + } + return m +} diff --git a/internal/temporalcli/commands.schedule_test.go b/internal/temporalcli/commands.schedule_test.go index d828e55ea..468faf991 100644 --- a/internal/temporalcli/commands.schedule_test.go +++ b/internal/temporalcli/commands.schedule_test.go @@ -553,3 +553,44 @@ func (s *SharedServerSuite) TestSchedule_Memo_Update() { return j.Schedule.Action.StartWorkflow.Memo.Fields.Bar.Data == "Mg==" }, 10*time.Second, 100*time.Millisecond) } + +func (s *SharedServerSuite) TestSchedule_ListMatchingTimes() { + schedId, _, res := s.createSchedule("--interval", "1h") + s.NoError(res.Err) + + now := time.Now().UTC() + startTime := now.Format(time.RFC3339) + endTime := now.Add(5 * time.Hour).Format(time.RFC3339) + + // text output + res = s.Execute( + "schedule", "list-matching-times", + "--address", s.Address(), + "-s", schedId, + "--start-time", startTime, + "--end-time", endTime, + ) + s.NoError(res.Err) + // should have timestamps in output + s.Contains(res.Stdout.String(), "UTC") + + // json output + res = s.Execute( + "schedule", "list-matching-times", + "--address", s.Address(), + "-s", schedId, + "--start-time", startTime, + "--end-time", endTime, + "-o", "json", + ) + s.NoError(res.Err) + // should parse as JSON array + //var times []string + //s.NoError(json.Unmarshal(res.Stdout.Bytes(), ×)) + + var times []struct { + Time string `json:"time"` + } + s.NoError(json.Unmarshal(res.Stdout.Bytes(), ×)) + s.NotEmpty(times) +} diff --git a/internal/temporalcli/commands.server.go b/internal/temporalcli/commands.server.go index 90c5b2fe3..0ce0ae782 100644 --- a/internal/temporalcli/commands.server.go +++ b/internal/temporalcli/commands.server.go @@ -188,10 +188,11 @@ func persistentClusterID() string { // If there is not a database file in use, we want a cluster ID to be the same // for every re-run, so we set it as an environment config in a special env // file. We do not error if we can neither read nor write the file. + file := defaultDeprecatedEnvConfigFile("temporalio", "version-info") if file == "" { // No file, can do nothing here - return uuid.NewString() + return "dev-server-" + uuid.NewString() } // Try to get existing first env, _ := readDeprecatedEnvConfigFile(file) @@ -199,7 +200,7 @@ func persistentClusterID() string { return id } // Create and try to write - id := uuid.NewString() + id := "dev-server-" + uuid.NewString() _ = writeDeprecatedEnvConfigFile(file, map[string]map[string]string{"default": {"cluster-id": id}}) return id } diff --git a/internal/temporalcli/commands.yaml b/internal/temporalcli/commands.yaml index 78bdf3513..6f9304329 100644 --- a/internal/temporalcli/commands.yaml +++ b/internal/temporalcli/commands.yaml @@ -2320,6 +2320,36 @@ commands: - overlap-policy - schedule-id + - name: temporal schedule list-matching-times + summary: List matching times for a Schedule (Experimental) + description: | + + Note: This is an experimental feature and may change in the future. + + List the times a Schedule would fire within a given time range. + Use this command to preview when a Schedule will trigger Workflow + Executions without actually running them. + + For example: + + ``` + temporal schedule list-matching-times \ + --schedule-id "YourScheduleId" \ + --start-time "2024-01-01T00:00:00Z" \ + --end-time "2024-01-31T23:59:59Z" + ``` + options: + - name: start-time + type: timestamp + description: Start of time range to list matching times. + required: true + - name: end-time + type: timestamp + description: End of time range to list matching times. + required: true + option-sets: + - schedule-id + - name: temporal schedule create summary: Create a new Schedule description: |