diff --git a/src/frontend/apiserver/backend/convert_test.go b/src/frontend/apiserver/backend/convert_test.go index 6d60574..a3ff57e 100644 --- a/src/frontend/apiserver/backend/convert_test.go +++ b/src/frontend/apiserver/backend/convert_test.go @@ -916,3 +916,44 @@ func TestResolveExecUser(t *testing.T) { }) } } + +func TestFormatJobID(t *testing.T) { + tests := []struct { + name string + jobID int64 + want string + }{ + { + name: "real job from t1", + jobID: 73930964699447296, // cluster=1 apiserver=0 ts=1786585485 seq=0 + want: "1-0-1786585485-0", + }, + { + name: "pre-cluster1 job", + // cluster=0 apiserver=7 ts=1786416651 seq=138313 + jobID: int64(uint64(0)<<56 | uint64(7)<<52 | uint64(1786416651)<<20 | uint64(138313)), + want: "0-7-1786416651-138313", + }, + { + name: "zero", + jobID: 0, + want: "0-0-0-0", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := FormatJobID(tt.jobID); got != tt.want { + t.Errorf("FormatJobID(%d) = %q, want %q", tt.jobID, got, tt.want) + } + }) + } +} + +func TestFormatJobID_ArrayChild(t *testing.T) { + // Array child: tag 0x01 set, seq split into arrayjob(high)<<15 and idx(low). + // cluster=1 apiserver=0 ts=1786585485, arrayjob=3<<15, idx=5. + jobID := int64(uint64(1)<<56 | uint64(0)<<52 | uint64(1786585485)<<20 | uint64(0x01)<<62 | uint64(3)<<15 | uint64(5)) + if got, want := FormatJobID(jobID), "1-0-1786585485-98304[5]"; got != want { + t.Errorf("FormatJobID(array) = %q, want %q", got, want) + } +} diff --git a/src/frontend/apiserver/config/rbac.yaml b/src/frontend/apiserver/config/rbac.yaml index 76225ae..59ad0fc 100644 --- a/src/frontend/apiserver/config/rbac.yaml +++ b/src/frontend/apiserver/config/rbac.yaml @@ -17,7 +17,7 @@ permissions: - hostgroup:control # add - usergroup:view # view usergroup info - resource:view # view resource info - - event:subscribe # subscribe to the live job-event SSE stream (scheduler-agent Phase 2) + - event:view # subscribe to the live job-event SSE stream (scheduler-agent Phase 2) - ar:view # view advanced reservation info - ar:submit # submit advanced reservation - ar:control # delete, modify, create @@ -171,7 +171,7 @@ roles: - action: cluster:view domain: - type: all - - action: event:subscribe + - action: event:view domain: - type: all @@ -208,6 +208,6 @@ roles: - action: job:control domain: - type: all - - action: event:subscribe + - action: event:view domain: - type: all diff --git a/src/frontend/apiserver/controllers/jobs.go b/src/frontend/apiserver/controllers/jobs.go index 44976d6..94d0993 100644 --- a/src/frontend/apiserver/controllers/jobs.go +++ b/src/frontend/apiserver/controllers/jobs.go @@ -1515,9 +1515,10 @@ func (j JobController) CreateJob(ctx *gin.Context) { logger.Info("Job submission successfully from ", zap.String("client_ip", ctx.ClientIP()), zap.Int64("jobID", primaryJobID)) response := models.JobSubmitResponse{ - Message: fmt.Sprintf("Job <%d> is submitted to default queue <%s>.", primaryJobID, req.QueueName), - JobID: primaryJobID, - QueueName: req.QueueName, + Message: fmt.Sprintf("Job <%d> is submitted to default queue <%s>.", primaryJobID, req.QueueName), + JobID: primaryJobID, + JobIDHuman: backend.FormatJobID(primaryJobID), + QueueName: req.QueueName, } ReturnSuccess(ctx, common.CREATED, common.SUCCESS_MSG, response) } @@ -3144,6 +3145,28 @@ func jobArrayIdx(row utils.OrderMap) int { func processJobData(rows []utils.OrderMap, pagetoken string) utils.QueryResult { logger.Debug("Enter processJobData() function", zap.Any("rows", rows)) for _, row := range rows { + // Human-readable job ID (cluster-apiserver-timestamp-seq) so clients + // (MCP server, CLI) can cross-reference with the xce_test/xcecli dash + // format, which parses only that form. Data correctness lives here, + // not in the clients. FormatJobID is the apiserver's own C fmt_jobid + // equivalent (backend.FormatJobID). + if uid, exist := row.Data["unique_job_id"]; exist { + var jobID int64 + switch v := uid.(type) { + case int64: + jobID = v + case int: + jobID = int64(v) + case int32: + jobID = int64(v) + case float64: + jobID = int64(v) + } + if jobID != 0 { + row.Data["job_id_human"] = backend.FormatJobID(jobID) + } + } + if v, exist := row.Data["job_status"]; exist { if v != nil { jobStatus, _ := utils.IntOrInt64(v) diff --git a/src/frontend/apiserver/controllers/processjobdata_test.go b/src/frontend/apiserver/controllers/processjobdata_test.go new file mode 100644 index 0000000..2ec26ac --- /dev/null +++ b/src/frontend/apiserver/controllers/processjobdata_test.go @@ -0,0 +1,57 @@ +package controllers + +import ( + "testing" + + "apiserver/utils" + + "go.uber.org/zap" +) + +func init() { + utils.Log = zap.NewNop() +} + +// TestProcessJobData_AddsJobIDHuman verifies every job row gets a +// job_id_human field (cluster-apiserver-timestamp-seq) so clients can +// cross-reference with the xce_test/xcecli dash format. +func TestProcessJobData_AddsJobIDHuman(t *testing.T) { + rows := []utils.OrderMap{ + { + Fields: []string{"job_id", "unique_job_id", "job_status"}, + Data: map[string]interface{}{ + "job_id": int64(73930964699447296), + "unique_job_id": int64(73930964699447296), + "job_status": int64(4), // DONE + }, + }, + } + res := processJobData(rows, "") + if len(res.Rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(res.Rows)) + } + got, ok := res.Rows[0].Data["job_id_human"] + if !ok { + t.Fatal("job_id_human missing from processed row") + } + if got != "1-0-1786585485-0" { + t.Errorf("job_id_human = %v, want 1-0-1786585485-0", got) + } +} + +func TestProcessJobData_NoJobIDHumanWhenZero(t *testing.T) { + rows := []utils.OrderMap{ + { + Fields: []string{"job_id", "unique_job_id", "job_status"}, + Data: map[string]interface{}{ + "job_id": int64(0), + "unique_job_id": int64(0), + "job_status": int64(1), + }, + }, + } + res := processJobData(rows, "") + if _, ok := res.Rows[0].Data["job_id_human"]; ok { + t.Error("job_id_human should not be set for zero job id") + } +} diff --git a/src/frontend/apiserver/models/jobs.go b/src/frontend/apiserver/models/jobs.go index b85cca8..e8eaee3 100644 --- a/src/frontend/apiserver/models/jobs.go +++ b/src/frontend/apiserver/models/jobs.go @@ -695,6 +695,7 @@ type ShellLimit struct { type JobSubmitResponse struct { JobID int64 `json:"jobid"` + JobIDHuman string `json:"job_id_human"` // cluster-apiserver-timestamp-seq, for xce_test/xcecli cross-reference QueueName string `json:"queue_name"` Message string `json:"message"` } diff --git a/src/frontend/apiserver/router/routers.go b/src/frontend/apiserver/router/routers.go index c44c8c7..1b19752 100644 --- a/src/frontend/apiserver/router/routers.go +++ b/src/frontend/apiserver/router/routers.go @@ -150,7 +150,7 @@ func Router() *gin.Engine { eventsHandler := &controllers.EventsController{} // Server-Sent Events stream of job lifecycle events. Long-lived // connection; keep-alive comments are sent when idle. - eventsApi.GET("", controllers.RequirePermission("event:subscribe"), eventsHandler.SubscribeJobs) + eventsApi.GET("", controllers.RequirePermission("event:view"), eventsHandler.SubscribeJobs) } // ################ case: resources route diff --git a/src/frontend/schedulerclient/httpclient.go b/src/frontend/schedulerclient/httpclient.go index 7dd9156..b7b7add 100644 --- a/src/frontend/schedulerclient/httpclient.go +++ b/src/frontend/schedulerclient/httpclient.go @@ -355,6 +355,7 @@ func (hc *HTTPClient) getJobs(req *resty.Request, url string, filter JobFilter) numProcessors := j.rowInt("num_processors") urmJob := &URMJob{ ID: strconv.FormatInt(j.rowInt64("job_id"), 10), + HumanID: j.rowStr("job_id_human"), Name: j.rowStr("job_name"), Status: urmStatus, RawStatus: rawStatus, diff --git a/src/frontend/schedulerclient/types.go b/src/frontend/schedulerclient/types.go index 1159135..7923fc9 100644 --- a/src/frontend/schedulerclient/types.go +++ b/src/frontend/schedulerclient/types.go @@ -26,6 +26,7 @@ const ( // URMJob is the unified representation of a scheduler job. type URMJob struct { ID string `json:"id"` + HumanID string `json:"human_id"` // controller dash form (cluster-apiserver-ts-seq), set by the apiserver Name string `json:"name"` Status JobStatus `json:"status"` RawStatus string `json:"raw_status"` // backend-native status string diff --git a/src/frontend/schedulerclient/write.go b/src/frontend/schedulerclient/write.go index 8627e6f..2f319d9 100644 --- a/src/frontend/schedulerclient/write.go +++ b/src/frontend/schedulerclient/write.go @@ -36,9 +36,10 @@ type JobSpec struct { // SubmitJobResult is the apiserver's response to a successful job submission. type SubmitJobResult struct { - JobID int64 `json:"jobid"` // NB: lowercase field name in the apiserver response - QueueName string `json:"queue_name"` - Message string `json:"message"` + JobID int64 `json:"jobid"` // NB: lowercase field name in the apiserver response + JobIDHuman string `json:"job_id_human"` // cluster-apiserver-timestamp-seq (usable in xce_test/xcecli) + QueueName string `json:"queue_name"` + Message string `json:"message"` } // JobControlRequest is the body sent to POST /xce/v1/jobs/:jobids/control.