fix(catalyst-ui): JobDetail fetches /jobs/{id} with RAW colon, not %3A (#305 follow-up 3)

The browser auto-encodes `:` to `%3A` when encodeURIComponent is
applied to a path segment. Chi's router does NOT decode %3A before
matching the route, so every JobDetail fetch returned 404 against the
catalyst-api.

Live evidence (Playwright network log on otech wizard, 2026-04-30):

  GET https://console.openova.io/sovereign/api/v1/deployments/
      ce476aaf80731a46/jobs/ce476aaf80731a46%3Ainstall-seaweedfs
  → 404

Internal probe with the raw colon:

  wget http://localhost:8080/api/v1/deployments/.../jobs/
       ce476aaf80731a46:install-seaweedfs
  → 200

Result on the live deployment: every JobDetail page rendered the
"Execution metadata pending" placeholder even though the catalyst-api
DID have a valid execution to surface. Bug is in the FE encoder, not
the backend or the route.

Fix:
  - useJobDetail inserts jobId raw into the URL template. The colon
    is RFC 3986 path-safe so this is correct per spec.
  - deploymentId stays encodeURIComponent'd defensively (it's a hex
    string, no-op in practice, but the encode is cheap insurance).
  - Test now asserts the URL contains the raw `:` and rejects %3A.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hatice yildiz 2026-04-30 21:02:45 +02:00
parent 87c8626d92
commit dc2656cf3e
2 changed files with 17 additions and 3 deletions

View File

@ -296,10 +296,12 @@ describe('JobDetail — Exec Log tab wires the real execution id (regression for
json: () => Promise.resolve({ jobs: [job] }),
} as unknown as Response)
}
// /jobs/{jobId} (detail) → emit the executions[].
// /jobs/{jobId} (detail) → emit the executions[]. The jobId is
// inserted raw (NOT encodeURIComponent'd) so the colon survives
// — chi's path matcher rejects %3A. See useJobDetail.ts.
if (
url.endsWith(
`/v1/deployments/${encodeURIComponent(deploymentId)}/jobs/${encodeURIComponent(jobId)}`,
`/v1/deployments/${encodeURIComponent(deploymentId)}/jobs/${jobId}`,
)
) {
return Promise.resolve({
@ -376,6 +378,12 @@ describe('JobDetail — Exec Log tab wires the real execution id (regression for
.toBe(true)
// Synthetic `:latest` id MUST NOT appear in any URL.
expect(seenUrls.some((u) => u.includes(`${jobId}:latest`))).toBe(false)
// The jobId in the detail-fetch URL must use the RAW colon, not
// %3A — chi's path matcher does not decode %3A and would 404
// every detail fetch.
const detailHits = seenUrls.filter((u) => u.includes('/v1/deployments/') && u.includes('/jobs/'))
expect(detailHits.some((u) => u.endsWith(`/jobs/${jobId}`))).toBe(true)
expect(detailHits.some((u) => u.includes('%3A'))).toBe(false)
})
})

View File

@ -96,9 +96,15 @@ async function defaultFetchJobDetail(
deploymentId: string,
jobId: string,
): Promise<JobDetailResponse> {
// jobId is the canonical "<deploymentId>:<jobName>" string. The colon
// is RFC 3986 path-safe, but encodeURIComponent turns it into %3A,
// which chi's path matcher does NOT decode before route lookup —
// every detail fetch returns 404 with the encoded form. Insert the
// jobId raw; deploymentId is a 16-byte hex with no special chars so
// encoding it is a no-op.
const url =
`${API_BASE}/v1/deployments/${encodeURIComponent(deploymentId)}` +
`/jobs/${encodeURIComponent(jobId)}`
`/jobs/${jobId}`
const res = await fetch(url, { headers: { Accept: 'application/json' } })
if (res.status === 404) {
throw new JobNotFoundError(deploymentId, jobId)