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 <array-id>[0]. Control and list
help show <array-id>[k] and <array-id>[a-b]; list help now says -J.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rongsong Shen 2026-10-01 12:53:50 +00:00
parent bc616e8c4a
commit 58ca7abb36
Signed by untrusted user: rshen
GPG key ID: 80533536B0EB651C
7 changed files with 439 additions and 38 deletions

View file

@ -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

View file

@ -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 ("<array-id>[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 <array-id>[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 <array-id>[k] # kill element k of an array
xce jobs kill <array-id>[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 <array-id>[k] # stop element k of an array
xce jobs stop <array-id>[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 <array-id>[k] # resume element k of an array
xce jobs resume <array-id>[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 <array-id>[k] # checkpoint element k of an array
xce jobs checkpoint <array-id>[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()))

View file

@ -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 <decimal>[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 <array-id>[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{"<array-id>[k]", "<array-id>[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", "<array-id>[k]", "-J 'name[a-b]'"} {
if !strings.Contains(long, s) {
t.Errorf("list help lacks %q", s)
}
}
}

View file

@ -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 <array-id>[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

View file

@ -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 == "*" {

View file

@ -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)
}
}

View file

@ -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