fix(review HIGH 3): restore statusUrl on RunScenarioResponse + document 202→200 wire break (#3207)

Commit 7 silently shifted /api/scenario/v1/run-scenario's response
contract in two ways that the commit message covered only partially:

1. HTTP 202 Accepted → HTTP 200 OK
2. Dropped `statusUrl` string from the response body

The `statusUrl` drop was mentioned as "unused by SupplyChainPanel" but
not framed as a contract change. The 202 → 200 shift was not mentioned
at all. This is a same-version (v1 → v1) migration, so external callers
that key off either signal — `response.status === 202` or
`response.body.statusUrl` — silently branch incorrectly.

Evaluated options:
  (a) sebuf per-RPC status-code config — not available. sebuf's
      HttpConfig only models `path` and `method`; no status annotation.
  (b) Bump to scenario/v2 — judged heavier than the break itself for
      a single status-code shift. No in-repo caller uses 202 or
      statusUrl; the docs-level impact is containable.
  (c) Accept the break, document explicitly, partially restore.

Took option (c):

- Restored `statusUrl` in the proto (new field `string status_url = 3`
  on RunScenarioResponse). Server computes
  `/api/scenario/v1/get-scenario-status?jobId=<encoded job_id>` and
  populates it on every successful enqueue. External callers that
  followed this URL keep working unchanged.
- 202 → 200 is not recoverable inside the sebuf generator, so it is
  called out explicitly in two places:
    - docs/api-scenarios.mdx now includes a prominent `<Warning>` block
      documenting the v1→v1 contract shift + the suggested migration
      (branch on response body shape, not HTTP status).
    - RunScenarioResponse proto comment explains why 200 is the new
      success status on enqueue.
  OpenAPI bundle regenerated to reflect the restored statusUrl field.

- Regression test added in tests/scenario-handler.test.mjs pinning
  `statusUrl` to the exact URL-encoded shape — locks the invariant so
  a future proto rename or handler refactor can't silently drop it
  again.

From koala73 review (#3242 second-pass, HIGH new #3).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sebastien Melki
2026-04-21 22:56:53 +03:00
parent 209e369585
commit 23c821a189
8 changed files with 58 additions and 3 deletions

View File

@@ -60,10 +60,19 @@ Enqueues a job. Returns the assigned `jobId` the caller must poll.
```json
{
"jobId": "scenario:1713456789012:a1b2c3d4",
"status": "pending"
"status": "pending",
"statusUrl": "/api/scenario/v1/get-scenario-status?jobId=scenario%3A1713456789012%3Aa1b2c3d4"
}
```
- `statusUrl` — server-computed convenience URL. Callers that don't want to hardcode the status path can follow this directly (it URL-encodes the `jobId`).
<Warning>
**Wire-contract change (v1 → v1)** — the pre-sebuf-migration endpoint returned `202 Accepted` on successful enqueue; the migrated endpoint returns `200 OK`. No per-RPC status-code configuration is available in sebuf's HTTP annotations today, and introducing a `/v2` for a single status-code shift was judged heavier than the break itself.
If your integration branches on `response.status === 202`, switch to branching on response body shape (`response.body.status === "pending"` indicates enqueue success). `statusUrl` is preserved exactly as before and is a safe signal to key off.
</Warning>
**Errors**:
| Status | `message` | Cause |

File diff suppressed because one or more lines are too long

View File

@@ -165,9 +165,24 @@ components:
status:
type: string
description: Always "pending" at enqueue time.
statusUrl:
type: string
description: |-
Convenience URL the caller can use to poll this job's status.
Server-computed as `/api/scenario/v1/get-scenario-status?jobId=<job_id>`.
Restored after the v1 → v1 sebuf migration because external callers
may key off this field.
description: |-
RunScenarioResponse carries the enqueued job id. Clients poll
GetScenarioStatus with this id until status != "pending".
NOTE: the legacy (pre-sebuf) endpoint returned HTTP 202 Accepted on
enqueue; the sebuf-generated server emits 200 OK for all successful
responses (no per-RPC status-code configuration is available in the
current sebuf HTTP annotations). The 202 → 200 shift on a same-version
(v1 → v1) migration is called out in docs/api-scenarios.mdx and the
OpenAPI bundle; external consumers keying off `response.status === 202`
need to branch on response body shape instead.
GetScenarioStatusRequest:
type: object
properties:

View File

@@ -22,9 +22,22 @@ message RunScenarioRequest {
// RunScenarioResponse carries the enqueued job id. Clients poll
// GetScenarioStatus with this id until status != "pending".
//
// NOTE: the legacy (pre-sebuf) endpoint returned HTTP 202 Accepted on
// enqueue; the sebuf-generated server emits 200 OK for all successful
// responses (no per-RPC status-code configuration is available in the
// current sebuf HTTP annotations). The 202 → 200 shift on a same-version
// (v1 → v1) migration is called out in docs/api-scenarios.mdx and the
// OpenAPI bundle; external consumers keying off `response.status === 202`
// need to branch on response body shape instead.
message RunScenarioResponse {
// Generated job id of the form `scenario:{epoch_ms}:{8-char-suffix}`.
string job_id = 1;
// Always "pending" at enqueue time.
string status = 2;
// Convenience URL the caller can use to poll this job's status.
// Server-computed as `/api/scenario/v1/get-scenario-status?jobId=<job_id>`.
// Restored after the v1 → v1 sebuf migration because external callers
// may key off this field.
string status_url = 3;
}

View File

@@ -67,5 +67,13 @@ export async function runScenario(
throw new ApiError(502, 'Failed to enqueue scenario job', '');
}
return { jobId, status: 'pending' };
// statusUrl is a server-computed convenience URL preserved from the legacy
// /api/scenario/v1/run contract so external callers can keep polling via the
// response body rather than hardcoding the status path. See the proto comment
// on RunScenarioResponse for why this matters on a v1 → v1 migration.
return {
jobId,
status: 'pending',
statusUrl: `/api/scenario/v1/get-scenario-status?jobId=${encodeURIComponent(jobId)}`,
};
}

View File

@@ -10,6 +10,7 @@ export interface RunScenarioRequest {
export interface RunScenarioResponse {
jobId: string;
status: string;
statusUrl: string;
}
export interface GetScenarioStatusRequest {

View File

@@ -10,6 +10,7 @@ export interface RunScenarioRequest {
export interface RunScenarioResponse {
jobId: string;
status: string;
statusUrl: string;
}
export interface GetScenarioStatusRequest {

View File

@@ -105,6 +105,14 @@ describe('ScenarioService handlers', () => {
const res = await runScenario(proCtx(), { scenarioId: 'taiwan-strait-full-closure', iso2: '' });
assert.match(res.jobId, /^scenario:\d{13}:[a-z0-9]{8}$/);
assert.equal(res.status, 'pending');
// statusUrl preserved from the legacy v1 contract — server-computed,
// URL-encoded jobId, safe for callers to follow directly. Locked in
// because sebuf's 200-only convention breaks the 202/202-body pairing
// from the pre-migration contract, and statusUrl is the safe alternative.
assert.equal(
res.statusUrl,
`/api/scenario/v1/get-scenario-status?jobId=${encodeURIComponent(res.jobId)}`,
);
const pushCall = calls.find((c) => String(c.body).includes('RPUSH'));
assert.ok(pushCall, 'RPUSH pipeline must be dispatched');
const pushed = JSON.parse(pushCall.body);