From 58ca7abb369b6c8286362f67b5f3bcc3e5bd64a0 Mon Sep 17 00:00:00 2001 From: Rongsong Shen Date: Thu, 1 Oct 2026 12:53:50 +0000 Subject: [PATCH] feat(job-array-one-based-index): xcecli list and control use the selection parser (task 8.2) ID lists split only at commas outside brackets and every selection is validated before any request (control allows identity ranges, listing refuses them). A raw -J value with brackets is checked before the wildcard mapping, and the search rule and * stripping apply to the name part only (-J 'foo*[1]' lists element 1 of arrays whose name starts with foo); values without brackets map exactly as before. Control results print each item's own job ID when the server sends it, so a pre-upgrade element 0 shows as [0]. Control and list help show [k] and [a-b]; list help now says -J. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../specs/job-array-one-based-index/tasks.md | 2 +- src/frontend/xcecli/cmd/jobs/controller.go | 47 +++- .../xcecli/cmd/jobs/controller_test.go | 234 ++++++++++++++++++ src/frontend/xcecli/cmd/jobs/list.go | 5 +- src/frontend/xcecli/filters/job_filter.go | 44 ++-- .../xcecli/filters/job_filter_test.go | 138 +++++++++++ src/frontend/xcecli/models/jobs.go | 7 +- 7 files changed, 439 insertions(+), 38 deletions(-) create mode 100644 src/frontend/xcecli/cmd/jobs/controller_test.go create mode 100644 src/frontend/xcecli/filters/job_filter_test.go 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