Gateway P2.5 — OCR gpu-server compatibility façade
For agentic workers: extend the shipped P2 Job API. Match the P2 patterns (Store, Manager, Adapter, the AzureDIAdapter async-submit-then-poll shape). TDD;
go build/vet/test -racegreen per commit; commit trailerClaude-Session: https://claude.ai/code/session_01Cco5bXPQVNVk342NEW1pdT.
Goal: make the gateway a true drop-in for the OCR gpu-server so the ahu-ocr-tidyup engine's shipped integration flips by pointing GPU_SERVER_URL at the gateway and changing nothing else. The engine speaks the gpu-server's per-operation-path contract; P2's generic POST /jobs?model= API does not match it. This adds a compat façade in front of the same P2 machinery (queue, priority, on-prem, idempotency, audit).
Why: the P2 final state broke the OCR flip (see .superpowers/sdd/ocr-integration-review.md): 11/12 submit paths 404, no model sent, no registry entries, error-shape mismatch. Fix is entirely gateway-side (the engine is correct + hands-off).
The gpu-server contract to mirror (ground truth from ahu-ocr-tidyup/gpu-server/app/main.py)
12 job-submit POST paths (all 202 {job_id}), bodies are multipart/form or JSON — forwarded VERBATIM:
/jobs (generic, has a document_type form field), /ktp/cleanup, /override-scrutiny, /perbaikan/advisory, /perbaikan/ai-summary/extract, /perbaikan/surat-pernyataan/extract, /pendirian-pp/surat-pernyataan/extract, /bukti-setor/extract, /domisili/extract, /jual-beli/extract, /contact-info/extract, /berita-acara-rups/extract.
Poll GET /jobs/{job_id} → JobStatusResponse: {job_id, status: "queued"|"processing"|"completed"|"failed", progress?: int, stage?: string, result?: object, error?: string}. Unknown job → HTTP 404 (body {"detail":...}; the engine keys JOB_UNKNOWN off the 404 STATUS, not the body). GET /health.
The engine client (backend/src/lib/gpu-job-client.ts): POST to a per-op path, read {job_id}, poll ${base}/jobs/${job_id}, read result on completed / error on failed / queue_position+eta_ms on queued, resubmit-same-idem-key on 404, sends Idempotency-Key (never empty) + §2 headers.
Design — a façade over the existing P2 machinery
Reuse P2's Store + Manager + pool admission + audit unchanged. Add:
1. GPUServerJobAdapter (internal/jobs/adapter_gpuserver.go) — async submit-then-poll, like AzureDIAdapter
Run(ctx, job, payload, endpoint, report):
- POST payload (with job.ContentType) to endpoint + job's submit path → read {job_id} (the gpu-server's id).
- Poll endpoint/jobs/{gpuJobId} on a bounded interval until status ∈ {completed, failed}; queued|processing → keep polling, report(stage, progress) from the poll meta.
- On completed → return the result field (json.RawMessage). On failed → AdapterError{Retryable:false, Code:"GPUSERVER_FAILED", ...} carrying the gpu-server error string. Poll timeout → AdapterError (honor ctx). Bounded reads (readCapped), server-side APIKeyEnv if set. The submit path comes from the job — carry it on the Job (see #3).
2. Façade path table + routing (internal/jobs/facade.go)
A table mapping each of the 12 gpu-server submit paths → {opLabel string} (a human label like ktp-cleanup, used as the audit model dimension for per-op attribution). A Facade struct (Store, Reg, Cfg, Pools, Stats, TokenTenants, the gpu-server upstream id). For each path register POST {path} → submitFacade(path):
- auth (token→tenant like API.Submit), require Idempotency-Key (400 if absent), read body (bounded by MaxBodyBytes), resolve the single gpu-server upstream, on-prem check.
- Build a Job: Operation="ocr", Model=opLabel (per-op audit), SubmitPath=path (NEW field on Job so the adapter knows where to forward — add Job.SubmitPath), Class from X-Priority (default batch; but a human-waiting op may pass interactive), ContentType, DocHash/Pages from headers, IdemKey.
- Store.Create (dedup) → 202 {job_id} (gateway job id) + queue_position/eta.
- Register GET /jobs/{id} → a poll handler returning the gpu-server JobStatusResponse shape (top-level job_id,status,stage,progress,result,error + queue_position,eta_ms; 404 on unknown). Register GET /health (200 when not draining) if not already served by the gateway root — the gateway already has /healthz; the engine polls ${base}/jobs/... and may hit /health — mirror it (delegate to the existing healthz).
3. Job.SubmitPath + manager wiring
Add SubmitPath string to the Job (json submit_path,omitempty), set by the façade, read by GPUServerJobAdapter (via job). The manager passes the job to the adapter already; the adapter reads job.SubmitPath. For the generic P2 /jobs?model= path SubmitPath is empty → sync-http/azuredi/classify adapters ignore it (unchanged).
4. Poll-shape alignment (fixes M4/M5 from the review)
The façade's GET /jobs/{id} returns the gpu-server shape: result on completed, error (string) on failed (map from the job's ErrorCode/error), stage/progress on processing, queue_position/eta_ms on queued; unknown → 404 (top-level {"code":"JOB_UNKNOWN"} is fine — engine keys off status). This is a SEPARATE handler from the P2 generic Poll (which keeps its error_code/nested shape for CONVENTIONS-native engines) OR align both — implementer's call, but the façade poll MUST match the gpu-server shape the engine reads.
5. Config + enable
Add a gpu-server upstream: type: ocr-http, adapter: gpuserver-job, operation: ocr, endpoint: <real gpu-server url>, models: the 12 op-labels (so Resolve works if ever used) or just the id, slots. Gate the façade mount on the presence of a gpuserver-job upstream (like EnableJobs is gated on a job upstream) — EnableGPUServerFacade(store) on the server, called from main.go when such an upstream exists. Dormant otherwise (P2/P1 unchanged).
Tasks (each ends green; review after)
- Job.SubmitPath + GPUServerJobAdapter + tests — the async submit→poll adapter against an httptest stub gpu-server (submit→202{job_id}→poll running×2→completed{result}; failed→error; 404/timeout). Adapter selection in manager.adapterFor gains
case "gpuserver-job". - Façade routing + poll-shape + config + EnableGPUServerFacade + main.go gate + tests — all 12 paths route + forward the right SubmitPath; per-op
modellabel in audit; GET poll returns gpu-server shape; 404 JOB_UNKNOWN; idempotent resubmit → same job_id, one gpu-server submit; drain→503; dormancy (no gpuserver upstream ⇒ façade not mounted). A table-driven test hitting all 12 paths through the real mux against a stub gpu-server. - Example config + README + smoke + docs — add the
gpu-serverupstream to gateway.example.yaml (endpoint = the on-prem gpu-server, loopback per host survey); README "gpu-server compat façade" section; a Go smoke test through EnableGPUServerFacade + stub upstream (submit a path → poll → completed). Update the OCR integration prompt / a compat note that the flip is now aGPU_SERVER_URLbase-URL swap. Live-fire checklist (real gpu-server reachability) documented, not faked.
Invariants (inherit from P2 — reviewer gates)
On-prem enforcement in the façade submit path; Idempotency-Key required + dedup→existing job (one gpu-server submit per doc); slot-lease release every path (inherited via the manager); panic-recover (inherited); audit body-cap + doc_hash + per-op model label; drain 503; queued-vs-processing; JOB_UNKNOWN on unknown job; dormancy: with no gpuserver-job upstream configured, the gateway is byte-identical to P2. Namespace-safe tests.