diff --git a/src/frontend/apiserver/backend/convert.go b/src/frontend/apiserver/backend/convert.go index 997a3c1a..9bff7ea0 100644 --- a/src/frontend/apiserver/backend/convert.go +++ b/src/frontend/apiserver/backend/convert.go @@ -17,6 +17,10 @@ static xce_res_reason_t *job_res_reason(xce_job_preason_t *p, int i) { return p- // Helper to index into xce_alloc_t** (same CGO limitation as above) -- one // host's per-resource allocation list, scanned for the "slots" entry. static xce_alloc_t *host_alloc_res(xce_host_alloc_t *h, int i) { return h->allocs[i]; } + +// Helpers to index into job_pidinfo_t's pid/pgid arrays (same CGO limitation). +static unsigned int pidinfo_pid(job_pidinfo_t *p, int i) { return p->pids[i]; } +static unsigned int pidinfo_pgid(job_pidinfo_t *p, int i) { return p->pgids[i]; } */ import "C" @@ -198,6 +202,22 @@ func cJobToModel(job *C.xce_job_t) *models.JobInfo { } } } + // Pidinfo: the worker's start report — pids/pgids plus exdata, whose + // {"url": ...} JSON is the interactive session URL (ttyd/XJOB). The + // capnp Job wire carries pidinfo (Job.pidinfo@9 / JobPIDInfo.exdata@3) + // but the conversion dropped it, so interactive_job_url never reached + // the API and xcecli's browser flow could never fire. + if job.pidinfo != nil { + pi := job.pidinfo + p := &rawPidInfo{exdata: goString(pi.exdata)} + for i := 0; i < int(pi.npids); i++ { + p.pids = append(p.pids, int(C.pidinfo_pid(pi, C.int(i)))) + } + for i := 0; i < int(pi.npgids); i++ { + p.pgids = append(p.pgids, int(C.pidinfo_pgid(pi, C.int(i)))) + } + r.pidInfo = p + } return rawJobToModel(r) } diff --git a/src/frontend/apiserver/backend/convert_pure.go b/src/frontend/apiserver/backend/convert_pure.go index 1fab2d21..c3d159be 100644 --- a/src/frontend/apiserver/backend/convert_pure.go +++ b/src/frontend/apiserver/backend/convert_pure.go @@ -526,6 +526,17 @@ type rawRunUsage struct { peakCPU float64 } +// rawPidInfo mirrors the C job_pidinfo_t fields used by the Go model — +// the worker-reported process bookkeeping a job start carries. Exdata is +// the worker's start_exdata JSON; for interactive jobs (TTY/XJOB/DISPLAY, +// xce_job_type in src/include/xce/types.h) the worker packs the session +// URL into it as {"url": ...} (generate_exdata, xcecd_exdata.c). +type rawPidInfo struct { + pids []int + pgids []int + exdata string +} + // rawJobData mirrors the C xce_job_t fields used by the Go model. type rawJobData struct { jobID int64 @@ -538,7 +549,8 @@ type rawJobData struct { submit *rawSubmitData execHosts []rawHostSlot pendReason *rawPendReason - runUsage *rawRunUsage // nil until the job's first usage report + runUsage *rawRunUsage // nil until the job's first usage report + pidInfo *rawPidInfo // nil until the worker's start report } // rawWorkerData mirrors the C xce_worker_t fields used by the Go model. @@ -574,6 +586,26 @@ type rawResourceData struct { interval int } +// interactiveURLFromExdata extracts the interactive session URL from a job +// start's exdata JSON. The worker packs it as {"url": ...} +// (generate_exdata, xcecd_exdata.c); anything else — non-JSON, other keys, +// an empty/non-string url — yields "". The URL reaches the apiserver ONLY +// via this path: job_pidinfo_t.exdata rides the capnp Job wire (pidinfo@9) +// but was previously dropped by the conversion, leaving the API's +// interactive_job_url permanently empty and xcecli's openBrowser flow dead. +func interactiveURLFromExdata(exdata string) string { + if exdata == "" { + return "" + } + var obj struct { + URL string `json:"url"` + } + if err := json.Unmarshal([]byte(exdata), &obj); err != nil { + return "" + } + return obj.URL +} + // rawJobToModel maps a rawJobData to a models.JobInfo. // This pure-Go function contains the field-mapping logic and is tested without CGO. func rawJobToModel(r rawJobData) *models.JobInfo { @@ -676,6 +708,20 @@ func rawJobToModel(r rawJobData) *models.JobInfo { info.AvgCPUEfficiency = u.avgCPUEfficiency info.PeakCPUEfficiency = u.peakCPUEfficiency } + if p := r.pidInfo; p != nil { + // pid_info is a JSONBJFields entry: processJobData parses the JSONB + // string before the row leaves the API, so clients see an object + // with pids/pgids. The interactive URL is promoted to its own + // top-level field — it is what xcecli's waitForInteractiveURL and + // the --browse flow consume. + if data, err := json.Marshal(struct { + Pids []int `json:"pids,omitempty"` + Pgids []int `json:"pgids,omitempty"` + }{Pids: p.pids, Pgids: p.pgids}); err == nil { + info.PIDInfo = models.Jsonb{String: string(data)} + } + info.InteractiveJobURL = interactiveURLFromExdata(p.exdata) + } return info } diff --git a/src/frontend/apiserver/backend/convert_test.go b/src/frontend/apiserver/backend/convert_test.go index ac415e86..b10ccc05 100644 --- a/src/frontend/apiserver/backend/convert_test.go +++ b/src/frontend/apiserver/backend/convert_test.go @@ -523,6 +523,91 @@ func TestPendReasonName(t *testing.T) { } } +// Interactive URL: a worker start report's exdata {"url": ...} must surface +// as JobInfo.InteractiveJobURL (the field xcecli's waitForInteractiveURL +// polls and the --browse flow opens). Before this mapping the capnp wire +// carried pidinfo.exdata but the conversion dropped it, so the API's +// interactive_job_url was permanently empty. +func TestRawJobToModel_InteractiveURLFromPidInfoExdata(t *testing.T) { + r := rawJobData{ + jobID: 11, + state: 2, // JOB_STAT_RUN + pidInfo: &rawPidInfo{ + pids: []int{4321}, + pgids: []int{4320}, + exdata: `{"url": "http://10.0.1.5:7681"}`, + }, + } + + info := rawJobToModel(r) + + if want := "http://10.0.1.5:7681"; info.InteractiveJobURL != want { + t.Errorf("InteractiveJobURL: got %q, want %q", info.InteractiveJobURL, want) + } + var decoded struct { + Pids []int `json:"pids"` + Pgids []int `json:"pgids"` + } + if err := json.Unmarshal([]byte(info.PIDInfo.String), &decoded); err != nil { + t.Fatalf("PIDInfo should be valid JSON, got err: %v", err) + } + if len(decoded.Pids) != 1 || decoded.Pids[0] != 4321 { + t.Errorf("PIDInfo.pids: got %v, want [4321]", decoded.Pids) + } + if len(decoded.Pgids) != 1 || decoded.Pgids[0] != 4320 { + t.Errorf("PIDInfo.pgids: got %v, want [4320]", decoded.Pgids) + } +} + +// A normal (non-interactive) job's start report carries no exdata at all: +// no URL, and an empty pids/pgids payload marshals no keys at all. +func TestRawJobToModel_NonInteractivePidInfo(t *testing.T) { + r := rawJobData{ + jobID: 12, + state: 2, + pidInfo: &rawPidInfo{exdata: ""}, + } + + info := rawJobToModel(r) + + if info.InteractiveJobURL != "" { + t.Errorf("InteractiveJobURL: got %q, want empty for a non-interactive job", info.InteractiveJobURL) + } + if info.PIDInfo.String != "{}" { + t.Errorf("PIDInfo: got %q, want {}", info.PIDInfo.String) + } +} + +// Malformed exdata must yield "" — never an error path that breaks the +// whole job row (the exdata is worker-supplied JSON, only the URL key is +// meaningful here). +func TestInteractiveURLFromExdata_Malformed(t *testing.T) { + cases := []string{ + "", + "not json", + `{"other": 1}`, + `{"url": 42}`, + } + for _, ex := range cases { + if got := interactiveURLFromExdata(ex); got != "" { + t.Errorf("interactiveURLFromExdata(%q): got %q, want empty", ex, got) + } + } +} + +// No pidinfo at all (PEND job, or a start report that predates the exdata +// plumbing): URL and PIDInfo stay empty. +func TestRawJobToModel_NilPidInfo(t *testing.T) { + info := rawJobToModel(rawJobData{jobID: 13, state: 1}) + + if info.InteractiveJobURL != "" { + t.Errorf("InteractiveJobURL: got %q, want empty without pidinfo", info.InteractiveJobURL) + } + if info.PIDInfo.String != "" { + t.Errorf("PIDInfo: got %q, want empty without pidinfo", info.PIDInfo.String) + } +} + func TestRawJobToModel_Timestamps(t *testing.T) { r := rawJobData{ jobID: 1, diff --git a/src/frontend/apiserver/models/jobs.go b/src/frontend/apiserver/models/jobs.go index b06d0451..8b743d78 100644 --- a/src/frontend/apiserver/models/jobs.go +++ b/src/frontend/apiserver/models/jobs.go @@ -573,6 +573,13 @@ type JobInfo struct { PendingReason Jsonb `json:"pending_reason"` //change type, JSONB as []byte PIDInfo Jsonb `json:"pid_info"` // JSONB as []byte JobRusage Jsonb `json:"job_rusage"` // JSONB as []byte + // InteractiveJobURL is the ttyd/XJOB session URL the worker reported at + // job start (job_pidinfo_t.exdata's {"url": ...} JSON, extracted by the + // backend conversion). Empty for non-interactive jobs and for jobs whose + // start report predates the exdata plumbing. InteractiveJobURL in + // JobAdditionalFields (the query-field whitelist) must stay in sync with + // this name. + InteractiveJobURL string `json:"interactive_job_url"` } type JobExecHosts struct {