diff --git a/.kiro/specs/job-array-one-based-index/tasks.md b/.kiro/specs/job-array-one-based-index/tasks.md index 797696a7..e351529b 100644 --- a/.kiro/specs/job-array-one-based-index/tasks.md +++ b/.kiro/specs/job-array-one-based-index/tasks.md @@ -189,7 +189,7 @@ - _Requirements: 1.5, 5.5, 5.7, 5.8, 5.12, 5.14, 5.16_ - _Boundary: CliSel, CliSplit_ -- [ ] 8.2 Use the client parser in list and control commands +- [x] 8.2 Use the client parser in list and control commands - Split ID lists only at commas outside brackets; validate every selection before any request; control by identity allows ranges, listing does not - Check raw `-J` values containing brackets before wildcard mapping, taking the search rule from the name part only for bracketed values - Print control results from each result's own job ID when present, falling back to today's rendering; add the control result's job ID field to the client model diff --git a/src/frontend/xcecli/cmd/jobs/controller.go b/src/frontend/xcecli/cmd/jobs/controller.go index 8b36cf60..3c31dd9b 100755 --- a/src/frontend/xcecli/cmd/jobs/controller.go +++ b/src/frontend/xcecli/cmd/jobs/controller.go @@ -45,13 +45,16 @@ func (c *JobController) RunJobsControl() error { } } -// control jobs by job IDs +// control jobs by job IDs. Every selection is validated before any request +// is sent (a rejected selection rejects the whole command); control commands +// accept element ranges ("[a-b]"). func (c *JobController) controlJobsByIDs(apiClient *request.APIClient, token string) error { apiIDs := make([]string, 0, len(c.jobIDs)) for _, id := range c.jobIDs { - apiID, err := format.ToAPIJobID(strings.TrimSpace(id), true) + apiID, err := format.ToAPIJobID(id, true) if err != nil { - return fmt.Errorf("invalid job ID %q: %w", id, err) + // the selection error already quotes the trimmed selection + return err } apiIDs = append(apiIDs, apiID) } @@ -66,6 +69,13 @@ func (c *JobController) controlJobsByIDs(apiClient *request.APIClient, token str // control jobs by filter conditions func (c *JobController) controlJobsByFilter(apiClient *request.APIClient, token string) error { + // A -J value with '[' or ']' is an element selection by name: check it + // before sending. The value itself is sent unchanged (eq, as today). + if c.filter.JobName != "" { + if _, err := format.ParseNameSelection(c.filter.JobName); err != nil { + return err + } + } url := "/xce/v1/jobs/control" req := models.JobControlRequest{ @@ -94,15 +104,25 @@ func (c *JobController) printJobControlResponse(req interface{}, apiClient *requ return err } for _, data := range resp.Data.Accepted { - fmt.Printf("job <%s> %s\n", format.FormatArrayChildID(data.JobID, data.ArrayIdx), strings.TrimPrefix(data.Msg, "job ")) + fmt.Printf("job <%s> %s\n", controlResultID(data), strings.TrimPrefix(data.Msg, "job ")) } for _, data := range resp.Data.Failed { - fmt.Printf("job <%s> %s\n", format.FormatArrayChildID(data.JobID, data.ArrayIdx), strings.TrimPrefix(data.Msg, "job ")) + fmt.Printf("job <%s> %s\n", controlResultID(data), strings.TrimPrefix(data.Msg, "job ")) } return nil } +// controlResultID renders a control result's job: from its own job ID when +// the server sends one (so a pre-upgrade element 0 prints as [0]), +// otherwise from job_id + array_idx as before (older servers). +func controlResultID(item *models.JobControlResponseItem) string { + if item.UniqueJobID != 0 { + return format.FormatJobID(item.UniqueJobID) + } + return format.FormatArrayChildID(item.JobID, item.ArrayIdx) +} + // Parse parse command line arguments // Parse command line arguments func (c *JobController) ParseCmdArgs(cmd *cobra.Command, args []string) error { @@ -110,8 +130,8 @@ func (c *JobController) ParseCmdArgs(cmd *cobra.Command, args []string) error { if len(args) == 1 && args[0] == "0" { c.filter.AllJobs = true } else { - // parse job ID parameters - c.jobIDs = strings.Split(args[0], ",") + // parse job ID parameters: split only at commas outside [...] + c.jobIDs = format.SplitSelections(args[0]) return nil } } @@ -133,7 +153,10 @@ func NewKillJobsCmd(configManager *config.ConfigManager) *cobra.Command { Long: `Kill jobs by job IDs or filters. Examples: xce jobs kill jobid1,jobid2,... # kill jobs by job IDs + xce jobs kill [k] # kill element k of an array + xce jobs kill [a-b] # kill elements a through b of an array xce jobs kill -J jobname # kill jobs by job name + xce jobs kill -J 'jobname[k]' # kill element k of every array named jobname xce jobs kill -q queue_name # kill jobs in a specific queue xce jobs kill -u username # kill jobs by user`, RunE: func(cmd *cobra.Command, args []string) error { @@ -169,7 +192,10 @@ func NewStopJobsCmd(configManager *config.ConfigManager) *cobra.Command { Long: `Stop jobs by job IDs or filters. Examples: xce jobs stop jobid1,jobid2,... # stop jobs by job IDs + xce jobs stop [k] # stop element k of an array + xce jobs stop [a-b] # stop elements a through b of an array xce jobs stop -J jobname # stop jobs by job name + xce jobs stop -J 'jobname[k]' # stop element k of every array named jobname xce jobs stop -q queue_name # stop jobs in a specific queue xce jobs stop -u username # stop jobs by user`, RunE: func(cmd *cobra.Command, args []string) error { @@ -205,7 +231,10 @@ func NewResumeJobsCmd(configManager *config.ConfigManager) *cobra.Command { Long: `Resume jobs by job IDs or filters. Examples: xce jobs resume jobid1,jobid2,... # resume jobs by job IDs + xce jobs resume [k] # resume element k of an array + xce jobs resume [a-b] # resume elements a through b of an array xce jobs resume -J jobname # resume jobs by job name + xce jobs resume -J 'jobname[k]' # resume element k of every array named jobname xce jobs resume -u username # resume jobs by user`, RunE: func(cmd *cobra.Command, args []string) error { if err := controller.ParseCmdArgs(cmd, args); err != nil { @@ -237,7 +266,9 @@ func NewCheckPointJobsCmd(configManager *config.ConfigManager) *cobra.Command { Short: "Checkpoint jobs", Long: `Checkpoint jobs by job IDs or filters. Examples: - xce jobs checkpoint jobid1,jobid2,... # checkpoint jobs by job IDs`, + xce jobs checkpoint jobid1,jobid2,... # checkpoint jobs by job IDs + xce jobs checkpoint [k] # checkpoint element k of an array + xce jobs checkpoint [a-b] # checkpoint elements a through b of an array`, RunE: func(cmd *cobra.Command, args []string) error { if err := controller.ParseCmdArgs(cmd, args); err != nil { fmt.Println(strings.ToLower(err.Error())) diff --git a/src/frontend/xcecli/cmd/jobs/controller_test.go b/src/frontend/xcecli/cmd/jobs/controller_test.go new file mode 100644 index 00000000..ff401c9b --- /dev/null +++ b/src/frontend/xcecli/cmd/jobs/controller_test.go @@ -0,0 +1,234 @@ +package jobs + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strconv" + "strings" + "sync" + "testing" + + "xcecli/common/format" + "xcecli/common/request" + "xcecli/models" +) + +// Array-capable identity used by the tests below: tag 0, sequence 32768, so +// the low 15 (element) bits are clear (5.8). +const ( + testArrayWritten = "0-0-1700000000-32768" +) + +var testArrayKey = format.MakeJobID(0, 0, 1700000000, 0, 32768) + +// controlServer is an httptest server that records every request path and +// answers with the given control result. +type controlServer struct { + srv *httptest.Server + mu sync.Mutex + paths []string +} + +func newControlServer(t *testing.T, data models.JobControlResponseData) *controlServer { + t.Helper() + cs := &controlServer{} + cs.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cs.mu.Lock() + cs.paths = append(cs.paths, r.URL.Path) + cs.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(models.JobControlResponse{Code: 0, Msg: "ok", Data: &data}) + })) + t.Cleanup(cs.srv.Close) + return cs +} + +func (cs *controlServer) requests() []string { + cs.mu.Lock() + defer cs.mu.Unlock() + return append([]string(nil), cs.paths...) +} + +// captureStdout runs fn and returns what it printed to os.Stdout. +func captureStdout(t *testing.T, fn func() error) (string, error) { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + old := os.Stdout + os.Stdout = w + done := make(chan string) + go func() { + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + done <- buf.String() + }() + fnErr := fn() + os.Stdout = old + _ = w.Close() + return <-done, fnErr +} + +func newTestController(args string) (*JobController, error) { + c := NewJobController(nil, "kill") + return c, c.ParseCmdArgs(nil, []string{args}) +} + +// X[1-3] reaches the request path as [1-3] (control allows ranges). +func TestControlJobsByIDs_RangeReachesPathAsDecimal(t *testing.T) { + cs := newControlServer(t, models.JobControlResponseData{}) + c, err := newTestController(testArrayWritten + "[1-3]") + if err != nil { + t.Fatalf("ParseCmdArgs: %v", err) + } + if _, err := captureStdout(t, func() error { + return c.controlJobsByIDs(request.NewAPIClient(cs.srv.URL), "tok") + }); err != nil { + t.Fatalf("controlJobsByIDs: %v", err) + } + got := cs.requests() + want := "/xce/v1/jobs/" + strconv.FormatUint(testArrayKey, 10) + "[1-3]/control" + if len(got) != 1 || got[0] != want { + t.Fatalf("request paths = %v, want [%s]", got, want) + } +} + +// Two selections, one with a comma inside the brackets: split only outside +// brackets, and the rejected selection is reported as written, before any +// request. +func TestControlJobsByIDs_SplitsOutsideBracketsAndValidatesFirst(t *testing.T) { + cases := []struct { + args string + wantSub string + }{ + {testArrayWritten + "[1,3]," + testArrayWritten + "[2]", "'" + testArrayWritten + "[1,3]'"}, + {testArrayWritten + "[1], " + testArrayWritten + "[40000]", "'" + testArrayWritten + "[40000]'"}, + {testArrayWritten + "[2],0-0-1700000000-5[1]", "'0-0-1700000000-5[1]'"}, + } + for _, tc := range cases { + t.Run(tc.args, func(t *testing.T) { + cs := newControlServer(t, models.JobControlResponseData{}) + c, err := newTestController(tc.args) + if err == nil { + _, err = captureStdout(t, func() error { + return c.controlJobsByIDs(request.NewAPIClient(cs.srv.URL), "tok") + }) + } + if err == nil { + t.Fatalf("expected rejection for %q", tc.args) + } + if !strings.Contains(err.Error(), tc.wantSub) { + t.Errorf("error %q does not quote %s", err.Error(), tc.wantSub) + } + if n := len(cs.requests()); n != 0 { + t.Errorf("%d request(s) sent, want none", n) + } + }) + } +} + +// -J with a malformed element part is refused before any request; a valid +// bracketed -J is sent unchanged (eq, as today). +func TestControlJobsByFilter_NameSelectionCheckedFirst(t *testing.T) { + for _, bad := range []string{"foo[1]*", "foo [1]", "foo[40000]", "foo[3-1]", "foo[1"} { + t.Run(bad, func(t *testing.T) { + cs := newControlServer(t, models.JobControlResponseData{}) + c := NewJobController(nil, "kill") + c.filter.JobName = bad + _, err := captureStdout(t, func() error { + return c.controlJobsByFilter(request.NewAPIClient(cs.srv.URL), "tok") + }) + if err == nil { + t.Fatalf("expected rejection for -J %q", bad) + } + if !strings.Contains(err.Error(), "'"+strings.TrimSpace(bad)+"'") { + t.Errorf("error %q does not quote the selection", err.Error()) + } + if n := len(cs.requests()); n != 0 { + t.Errorf("%d request(s) sent, want none", n) + } + }) + } + t.Run("valid", func(t *testing.T) { + cs := newControlServer(t, models.JobControlResponseData{}) + c := NewJobController(nil, "kill") + c.filter.JobName = "foo[1-2]" + if _, err := captureStdout(t, func() error { + return c.controlJobsByFilter(request.NewAPIClient(cs.srv.URL), "tok") + }); err != nil { + t.Fatalf("controlJobsByFilter: %v", err) + } + if n := len(cs.requests()); n != 1 { + t.Fatalf("%d request(s) sent, want 1", n) + } + }) +} + +// A pre-upgrade element 0 result prints as [0] (8.3); without +// unique_job_id (older server) the result falls back to job_id + array_idx. +func TestControlResultPrinting(t *testing.T) { + elem0 := int64(testArrayKey | (uint64(1) << 62)) + data := models.JobControlResponseData{ + Accepted: []*models.JobControlResponseItem{ + {JobID: int64(testArrayKey), ArrayIdx: 0, UniqueJobID: elem0, Msg: "job killed"}, + {JobID: int64(testArrayKey), ArrayIdx: 2, Msg: "job killed"}, + }, + Failed: []*models.JobControlResponseItem{ + {JobID: int64(testArrayKey), ArrayIdx: 3, UniqueJobID: elem0 | 3, Msg: "job not found"}, + }, + } + cs := newControlServer(t, data) + c, err := newTestController(testArrayWritten + "[0]") + if err != nil { + t.Fatalf("ParseCmdArgs: %v", err) + } + out, err := captureStdout(t, func() error { + return c.controlJobsByIDs(request.NewAPIClient(cs.srv.URL), "tok") + }) + if err != nil { + t.Fatalf("controlJobsByIDs: %v", err) + } + want := []string{ + "job <" + testArrayWritten + "[0]> killed", + "job <" + testArrayWritten + "[2]> killed", + "job <" + testArrayWritten + "[3]> not found", + } + got := strings.Split(strings.TrimRight(out, "\n"), "\n") + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Fatalf("output:\n%s\nwant:\n%s", out, strings.Join(want, "\n")) + } +} + +// Control help shows how to name one element and a range of elements (9.2). +func TestControlHelpShowsElementForms(t *testing.T) { + for name, cmd := range map[string]string{ + "kill": NewKillJobsCmd(nil).Long, + "stop": NewStopJobsCmd(nil).Long, + "resume": NewResumeJobsCmd(nil).Long, + "checkpoint": NewCheckPointJobsCmd(nil).Long, + } { + for _, form := range []string{"[k]", "[a-b]"} { + if !strings.Contains(cmd, form) { + t.Errorf("%s help lacks %s", name, form) + } + } + } +} + +// List help names the -J flag (not -j) and shows an element selection. +func TestListHelpShowsElementForms(t *testing.T) { + long := NewListJobsCmd(nil).Long + if strings.Contains(long, "list -j ") { + t.Errorf("list help still documents -j for job name") + } + for _, s := range []string{"-J jobname", "[k]", "-J 'name[a-b]'"} { + if !strings.Contains(long, s) { + t.Errorf("list help lacks %q", s) + } + } +} diff --git a/src/frontend/xcecli/cmd/jobs/list.go b/src/frontend/xcecli/cmd/jobs/list.go index 1db79290..0a29f4d5 100755 --- a/src/frontend/xcecli/cmd/jobs/list.go +++ b/src/frontend/xcecli/cmd/jobs/list.go @@ -294,6 +294,7 @@ func NewListJobsCmd(configManager *config.ConfigManager) *cobra.Command { Example: xce jobs list # list all jobs xce jobs list jobid0,jobid1,...jobidn # list jobs by job id + xce jobs list [k] # list element k of an array xce jobs list -a # list run,done,pending,suspended jobs xce jobs list -d # list done jobs xce jobs list -d -r # list done and running jobs @@ -302,7 +303,9 @@ Example: xce jobs list -d -u all # list done jobs by all users xce jobs list -d -r -s -p -u username # list done and running and suspended and pending jobs by user xce jobs list -d -r -s -p -u username -q queuename # list done and running and suspended and pending jobs by user and queue - xce jobs list -j jobname # list jobs by job name + xce jobs list -J jobname # list jobs by job name + xce jobs list -J 'name[k]' # list element k of every array named name + xce jobs list -J 'name[a-b]' # list elements a through b of every array named name xce jobs list -Jd jobdescription # list jobs by job description xce jobs list --Lp licenseprojectname # list license project jobs xce jobs list -o "jobid submit_user ..." # list jobs by fields diff --git a/src/frontend/xcecli/filters/job_filter.go b/src/frontend/xcecli/filters/job_filter.go index 7f875b9e..008d42fe 100644 --- a/src/frontend/xcecli/filters/job_filter.go +++ b/src/frontend/xcecli/filters/job_filter.go @@ -2,7 +2,6 @@ package filters import ( "fmt" - "regexp" "strings" "xcecli/common/format" @@ -40,13 +39,12 @@ func (f *JobsFilter) HandleJobFilter(array_type string) (string, error) { var filters []string if f.JobIDs != "" { - jobIdStr := strings.Split(f.JobIDs, ",") + // split only at commas outside [...]; every selection is checked + // before the filter is built (queries refuse identity ranges) + jobIdStr := format.SplitSelections(f.JobIDs) apiIDs := make([]string, 0, len(jobIdStr)) for _, jobId := range jobIdStr { - if err := isValidJobId(jobId); err != nil { - return "", err - } - apiID, err := format.ToAPIJobID(strings.TrimSpace(jobId), false) + apiID, err := format.ToAPIJobID(jobId, false) if err != nil { return "", err } @@ -79,15 +77,27 @@ func (f *JobsFilter) HandleJobFilter(array_type string) (string, error) { } if f.JobName != "" { + // A value with '[' or ']' is an element selection by name: check the + // raw text before the wildcard mapping, so '*' can only be in the + // name part. + sel, err := format.ParseNameSelection(f.JobName) + if err != nil { + return "", err + } // Check for unsupported wildcard characters if strings.Contains(f.JobName, "%") || strings.Contains(f.JobName, "?") { return "", fmt.Errorf("unsupported wildcard characters (%% or ?) in job name: %s. Only * is supported", f.JobName) } - rule := generateSearchRule(f.JobName) + // The search rule and '*' removal apply to the name part only; for a + // value without brackets that is the whole value, as before. + namePart, elemPart := f.JobName, "" + if sel.Form != format.SelBare { + namePart, elemPart = sel.Name, sel.Raw[len(sel.Name):] + } + rule := generateSearchRule(namePart) // Remove wildcards from the actual search value - searchValue := f.JobName - searchValue = strings.ReplaceAll(searchValue, "*", "") + searchValue := strings.ReplaceAll(namePart, "*", "") + elemPart filters = append(filters, fmt.Sprintf("{job_name:%s:%s}", rule, searchValue)) } @@ -171,22 +181,6 @@ func GetJobStatusSet(f *JobsFilter) []string { return result } -var ( - reFmtJobID = regexp.MustCompile(`^\d+-\d+-\d+-\d+$`) - reFmtJobArrayID = regexp.MustCompile(`^\d+-\d+-\d+-\d+\[\d+\]$`) - reNumericID = regexp.MustCompile(`^\d+$`) - reNumericIndex = regexp.MustCompile(`^\d+\[\d+\]$`) -) - -func isValidJobId(jobID string) error { - s := strings.TrimSpace(jobID) - if reFmtJobID.MatchString(s) || reFmtJobArrayID.MatchString(s) || - reNumericID.MatchString(s) || reNumericIndex.MatchString(s) { - return nil - } - return fmt.Errorf("jobid '%s': must be numeric, numeric[idx], or cluster-apiserver-timestamp-seq[idx] format", jobID) -} - // Helper function, generates search rule func generateSearchRule(rule string) string { if rule == "*" { diff --git a/src/frontend/xcecli/filters/job_filter_test.go b/src/frontend/xcecli/filters/job_filter_test.go new file mode 100644 index 00000000..6203f88f --- /dev/null +++ b/src/frontend/xcecli/filters/job_filter_test.go @@ -0,0 +1,138 @@ +package filters + +import ( + "strconv" + "strings" + "testing" + + "xcecli/common/format" +) + +const testArrayWritten = "0-0-1700000000-32768" + +var testArrayDecimal = strconv.FormatUint(format.MakeJobID(0, 0, 1700000000, 0, 32768), 10) + +// ID lists split only at commas outside brackets; every selection is checked +// before a filter is built; listing refuses identity ranges. +func TestHandleJobFilter_JobIDs(t *testing.T) { + ok := []struct { + ids string + want string + }{ + {testArrayWritten + "[1]," + testArrayWritten + "[2]", + "{job_id:in:(" + testArrayDecimal + "[1]," + testArrayDecimal + "[2])}"}, + {" 123456 , " + testArrayWritten + "[0]", + "{job_id:in:(123456," + testArrayDecimal + "[0])}"}, + {testArrayDecimal + "[3]", "{job_id:in:(" + testArrayDecimal + "[3])}"}, + } + for _, tc := range ok { + t.Run(tc.ids, func(t *testing.T) { + f := &JobsFilter{JobIDs: tc.ids} + got, err := f.HandleJobFilter("") + if err != nil { + t.Fatalf("HandleJobFilter: %v", err) + } + if got != tc.want { + t.Errorf("filter = %q, want %q", got, tc.want) + } + }) + } + + bad := []struct { + ids string + quoted string + wantRule string + }{ + {testArrayWritten + "[1,3]," + testArrayWritten + "[2]", testArrayWritten + "[1,3]", "5.16"}, + {testArrayWritten + "[1-3]", testArrayWritten + "[1-3]", format.RuleQueryRange}, + {"100, " + testArrayWritten + "[40000]", testArrayWritten + "[40000]", "5.7"}, + {"0-0-1700000000-5[1]", "0-0-1700000000-5[1]", "5.8"}, + {testArrayWritten + " [1]", testArrayWritten + " [1]", "5.16"}, + } + for _, tc := range bad { + t.Run("reject "+tc.ids, func(t *testing.T) { + f := &JobsFilter{JobIDs: tc.ids} + _, err := f.HandleJobFilter("") + if err == nil { + t.Fatalf("expected rejection") + } + if !strings.Contains(err.Error(), "'"+tc.quoted+"'") { + t.Errorf("error %q does not quote %q", err.Error(), tc.quoted) + } + se, isSel := err.(*format.SelError) + if !isSel || se.Rule != tc.wantRule { + t.Errorf("error %#v, want *SelError rule %s", err, tc.wantRule) + } + }) + } +} + +// -J: a bracketed value is checked raw first, then the search rule and '*' +// stripping come from the name part only; plain values keep today's mapping. +func TestHandleJobFilter_JobName(t *testing.T) { + ok := []struct { + name string + want string + }{ + {"foo", "{job_name:eq:foo}"}, + {"foo*", "{job_name:startwith:foo}"}, + {"*foo", "{job_name:endwith:foo}"}, + {"*foo*", "{job_name:contains:foo}"}, + {"*", "{job_name:contains:}"}, + {"foo[1]", "{job_name:eq:foo[1]}"}, + {"foo*[1]", "{job_name:startwith:foo[1]}"}, + {"*foo[1-3]", "{job_name:endwith:foo[1-3]}"}, + {"*foo*[2]", "{job_name:contains:foo[2]}"}, + {"*[1]", "{job_name:contains:[1]}"}, + {"[1]", "{job_name:eq:[1]}"}, + {" foo[1] ", "{job_name:eq:foo[1]}"}, + } + for _, tc := range ok { + t.Run(tc.name, func(t *testing.T) { + f := &JobsFilter{JobName: tc.name, AllJobInfoFlag: true} + got, err := f.HandleJobFilter("") + if err != nil { + t.Fatalf("HandleJobFilter: %v", err) + } + if !strings.Contains(got, tc.want) { + t.Errorf("filter = %q, want it to contain %q", got, tc.want) + } + }) + } + + bad := []struct { + name string + rule string + }{ + {"foo[1]*", "5.16"}, + {"foo[1*]", "5.16"}, + {"foo [1]", "5.16"}, + {"foo[1", "5.16"}, + {"foo]", "5.16"}, + {"foo[a]", "5.16"}, + {"foo[40000]", "5.7"}, + {"foo[3-1]", "5.5"}, + } + for _, tc := range bad { + t.Run("reject "+tc.name, func(t *testing.T) { + f := &JobsFilter{JobName: tc.name, AllJobInfoFlag: true} + _, err := f.HandleJobFilter("") + if err == nil { + t.Fatalf("expected rejection") + } + se, isSel := err.(*format.SelError) + if !isSel || se.Rule != tc.rule { + t.Errorf("error %#v, want *SelError rule %s", err, tc.rule) + } + if !strings.Contains(err.Error(), "'"+strings.TrimSpace(tc.name)+"'") { + t.Errorf("error %q does not quote the selection", err.Error()) + } + }) + } + + // today's wildcard rejection still applies + f := &JobsFilter{JobName: "foo%", AllJobInfoFlag: true} + if _, err := f.HandleJobFilter(""); err == nil || !strings.Contains(err.Error(), "unsupported wildcard") { + t.Errorf("foo%%: err = %v, want unsupported wildcard", err) + } +} diff --git a/src/frontend/xcecli/models/jobs.go b/src/frontend/xcecli/models/jobs.go index 03940e05..eb8ebb86 100755 --- a/src/frontend/xcecli/models/jobs.go +++ b/src/frontend/xcecli/models/jobs.go @@ -452,9 +452,10 @@ type JobControlResponseData struct { } type JobControlResponseItem struct { - JobID int64 `json:"job_id"` - ArrayIdx int `json:"array_idx,omitempty"` - Msg string `json:"msg"` + JobID int64 `json:"job_id"` + ArrayIdx int `json:"array_idx,omitempty"` + UniqueJobID int64 `json:"unique_job_id,omitempty"` // the result's own job ID (absent from older servers) + Msg string `json:"msg"` } // job array statistics data structure