fix(frontend): peek and restart take <array-id>[k] and written job IDs

- apiserver peek (parseJobIDParam): <array-id>[k] now resolves to the
  element; it used to drop [k] and look up the array identity itself
  (and panicked in parseArrayName on some inputs). A job number still
  names that job exactly (an element by its own XCE_JOBID); ranges and
  invalid selections are rejected.
- apiserver restart: the decimal-only pre-check (isValidJobId, now
  unused and removed) rejected the written form c-a-ts-seq[k];
  ParseIdentitySelection, already used for the lookup, validates it.
- xcecli jobs restart converts its argument with ToAPIJobID like the
  control commands, so written IDs work and invalid ones fail locally.
- docs: the peek/restart caveat in the Job arrays section.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rongsong Shen 2026-10-02 09:42:27 +00:00
parent a65146a9ae
commit c15580a7f3
Signed by untrusted user: rshen
GPG key ID: 80533536B0EB651C
7 changed files with 126 additions and 35 deletions

View file

@ -839,12 +839,6 @@ func (j JobController) RestartJobByID(ctx *gin.Context) {
logger.Debug("Receive job restart request:",
zap.String("jobId", jobIDStr), zap.String("restarter", restarter))
if err = isValidJobId(jobIDStr); err != nil {
logger.Error(err.Error())
ReturnError(ctx, common.INVALID_PARAMS_CODE, common.INVALID_PARAMS_MSG+": "+err.Error(), nil)
return
}
// check post body whether correct
var req models.JobRestartRequest
if err := utils.BindJSONStrict(ctx, &req); err != nil {
@ -1775,15 +1769,6 @@ func isValidArrayName(arrayName string) error {
return nil
}
func isValidJobId(jobID string) error {
matched, _ := regexp.MatchString(`^\d+$`, jobID)
matchedIndex, _ := regexp.MatchString(`^\d+\[\d+\]$`, jobID)
if !matched && !matchedIndex {
return fmt.Errorf("jobid '%s' contains invalid characters. Allowed characters are 0-9 or with index value", jobID)
}
return nil
}
func validateAskedHosts(askedHosts string) error {
if askedHosts == "" {
return nil // Nothing to validate

View file

@ -1470,6 +1470,8 @@ func TestJobHandler_RestartNamedJob(t *testing.T) {
}{
{"ordinary job by decimal id", strconv.FormatUint(ord, 10), "ordinary"},
{"array element by <array-id>[k]", strconv.FormatUint(arr, 10) + "[2]", "elem-2"},
{"array element by written <array-id>[k]", backend.FormatJobID(int64(arr)) + "[2]", "elem-2"},
{"ordinary job by written id", backend.FormatJobID(int64(ord)), "ordinary"},
}
for _, c := range cases {
before := len(f.submitted)

View file

@ -9,7 +9,6 @@ import (
"apiserver/common"
"apiserver/models"
"apiserver/store"
"apiserver/utils"
logger "apiserver/utils"
@ -220,25 +219,29 @@ func resolveOutputPath(path string, job models.JobInfo) string {
return path
}
// parseJobIDParam parses a job ID string and returns the corresponding JobInfo.
// parseJobIDParam parses the job peek names and returns the corresponding
// JobInfo: a job number (an ordinary job, or an element by its own number,
// XCE_JOBID) names that job exactly; <array-id>[k] names element k. A range
// or an invalid selection is an error -- peek shows one job.
func parseJobIDParam(jobidStr string) (models.JobInfo, error) {
// Use the same parsing logic as GetJobByIDs
jobArrInfo, err := parseArrayName(jobidStr)
if err != nil {
return models.JobInfo{}, err
raw := strings.TrimSpace(jobidStr)
if n, err := strconv.ParseUint(raw, 10, 64); err == nil {
return models.JobInfo{Unique_JobID: int64(n), ArrayIdx: backend.ExtractArrayIdx(n)}, nil
}
if !utils.IsNumeric(jobArrInfo.KeyID) {
return models.JobInfo{}, fmt.Errorf("bad jobid: %s", jobArrInfo.KeyID)
sel, selErr := ParseIdentitySelection(raw)
if selErr != nil {
return models.JobInfo{}, selErr
}
uniqueJobID, err := strconv.ParseInt(jobArrInfo.KeyID, 10, 64)
if err != nil {
return models.JobInfo{}, fmt.Errorf("bad jobid: %s", jobArrInfo.KeyID)
switch sel.Form {
case SelSingle:
return models.JobInfo{
Unique_JobID: int64(backend.MakeElementID(sel.Key, sel.Lo)),
ArrayIdx: sel.Lo,
}, nil
case SelRange:
return models.JobInfo{}, fmt.Errorf("'%s' names several jobs; peek one element as <array-id>[k]", raw)
default:
return models.JobInfo{Unique_JobID: int64(sel.Key)}, nil
}
return models.JobInfo{
Unique_JobID: uniqueJobID,
ArrayIdx: 0, // peek defaults to base job; array idx can be added later
}, nil
}

View file

@ -0,0 +1,50 @@
package controllers
import (
"strconv"
"testing"
"apiserver/backend"
)
// Peek names one job: an ordinary job or element by its own job number
// (XCE_JOBID), or an element as <array-id>[k]; parseJobIDParam used to drop
// the [k] and look up the array identity itself.
func TestParseJobIDParam(t *testing.T) {
arr := handlerTestArrayBase + 50<<15
elem2 := backend.MakeElementID(arr, 2)
ord := handlerTestOrdinaryBase + 51<<15
human := backend.FormatJobID(int64(arr))
ok := []struct {
in string
want uint64
}{
{strconv.FormatUint(ord, 10), ord},
{strconv.FormatUint(elem2, 10), elem2}, // the element's own number
{strconv.FormatUint(arr, 10) + "[2]", elem2},
{human + "[2]", elem2},
{" " + strconv.FormatUint(arr, 10) + "[2] ", elem2},
}
for _, c := range ok {
got, err := parseJobIDParam(c.in)
if err != nil {
t.Errorf("%q: unexpected error %v", c.in, err)
continue
}
if uint64(got.Unique_JobID) != c.want {
t.Errorf("%q: job %d, want %d", c.in, got.Unique_JobID, c.want)
}
}
for _, in := range []string{
strconv.FormatUint(arr, 10) + "[1-3]", // a range is several jobs
strconv.FormatUint(arr, 10) + "[40000]",
"garbage",
"",
} {
if _, err := parseJobIDParam(in); err == nil {
t.Errorf("%q: want an error", in)
}
}
}