mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-26 01:24:59 +02:00
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>
1 line
9.4 KiB
JSON
1 line
9.4 KiB
JSON
{"components":{"schemas":{"Error":{"description":"Error is returned when a handler encounters an error. It contains a simple error message that the developer can customize.","properties":{"message":{"description":"Error message (e.g., 'user not found', 'database connection failed')","type":"string"}},"type":"object"},"FieldViolation":{"description":"FieldViolation describes a single validation error for a specific field.","properties":{"description":{"description":"Human-readable description of the validation violation (e.g., 'must be a valid email address', 'required field missing')","type":"string"},"field":{"description":"The field path that failed validation (e.g., 'user.email' for nested fields). For header validation, this will be the header name (e.g., 'X-API-Key')","type":"string"}},"required":["field","description"],"type":"object"},"GetScenarioStatusRequest":{"description":"GetScenarioStatusRequest polls the worker result for an enqueued job id.","properties":{"jobId":{"description":"Job id of the form `scenario:{epoch_ms}:{8-char-suffix}`. Path-traversal\n guarded by JOB_ID_RE in the handler.","pattern":"^scenario:[0-9]{13}:[a-z0-9]{8}$","type":"string"}},"required":["jobId"],"type":"object"},"GetScenarioStatusResponse":{"description":"GetScenarioStatusResponse reflects the worker's lifecycle state.\n \"pending\" — no key yet (job still queued or very-recent enqueue).\n \"processing\" — worker has claimed the job but hasn't completed compute.\n \"done\" — compute succeeded; `result` is populated.\n \"failed\" — compute errored; `error` is populated.","properties":{"error":{"description":"Populated only when status == \"failed\".","type":"string"},"result":{"$ref":"#/components/schemas/ScenarioResult"},"status":{"type":"string"}},"type":"object"},"ListScenarioTemplatesRequest":{"type":"object"},"ListScenarioTemplatesResponse":{"properties":{"templates":{"items":{"$ref":"#/components/schemas/ScenarioTemplate"},"type":"array"}},"type":"object"},"RunScenarioRequest":{"description":"RunScenarioRequest enqueues a scenario job on the scenario-queue:pending\n Upstash list for the async scenario-worker to pick up.","properties":{"iso2":{"description":"Optional 2-letter ISO country code to scope the impact computation.\n When absent, the worker computes for all countries with seeded exposure.","pattern":"^([A-Z]{2})?$","type":"string"},"scenarioId":{"description":"Scenario template id — must match an entry in SCENARIO_TEMPLATES.","maxLength":128,"minLength":1,"type":"string"}},"required":["scenarioId"],"type":"object"},"RunScenarioResponse":{"description":"RunScenarioResponse carries the enqueued job id. Clients poll\n GetScenarioStatus with this id until status != \"pending\".\n\n NOTE: the legacy (pre-sebuf) endpoint returned HTTP 202 Accepted on\n enqueue; the sebuf-generated server emits 200 OK for all successful\n responses (no per-RPC status-code configuration is available in the\n current sebuf HTTP annotations). The 202 → 200 shift on a same-version\n (v1 → v1) migration is called out in docs/api-scenarios.mdx and the\n OpenAPI bundle; external consumers keying off `response.status === 202`\n need to branch on response body shape instead.","properties":{"jobId":{"description":"Generated job id of the form `scenario:{epoch_ms}:{8-char-suffix}`.","type":"string"},"status":{"description":"Always \"pending\" at enqueue time.","type":"string"},"statusUrl":{"description":"Convenience URL the caller can use to poll this job's status.\n Server-computed as `/api/scenario/v1/get-scenario-status?jobId=\u003cjob_id\u003e`.\n Restored after the v1 → v1 sebuf migration because external callers\n may key off this field.","type":"string"}},"type":"object"},"ScenarioImpactCountry":{"description":"ScenarioImpactCountry carries a single country's scenario impact score.","properties":{"impactPct":{"description":"Impact as a 0-100 share of the worst-hit country.","format":"int32","type":"integer"},"iso2":{"description":"2-letter ISO country code.","type":"string"},"totalImpact":{"description":"Raw weighted impact value aggregated across the country's exposed HS2\n chapters. Relative-only — not a currency amount.","format":"double","type":"number"}},"type":"object"},"ScenarioResult":{"description":"ScenarioResult is the computed payload the scenario-worker writes back\n under the `scenario-result:{job_id}` Redis key. Populated only when\n GetScenarioStatusResponse.status == \"done\".","properties":{"affectedChokepointIds":{"items":{"description":"Chokepoint ids disrupted by this scenario.","type":"string"},"type":"array"},"template":{"$ref":"#/components/schemas/ScenarioResultTemplate"},"topImpactCountries":{"items":{"$ref":"#/components/schemas/ScenarioImpactCountry"},"type":"array"}},"type":"object"},"ScenarioResultTemplate":{"description":"ScenarioResultTemplate carries template parameters echoed into the worker's\n computed result so clients can render them without re-looking up the\n template registry.","properties":{"costShockMultiplier":{"description":"Freight cost multiplier applied on top of bypass corridor costs.","format":"double","type":"number"},"disruptionPct":{"description":"0-100 percent of chokepoint capacity blocked.","format":"int32","type":"integer"},"durationDays":{"description":"Estimated duration of disruption in days.","format":"int32","type":"integer"},"name":{"description":"Display name (worker derives this from affected_chokepoint_ids; may be\n `tariff_shock` for tariff-type scenarios).","type":"string"}},"type":"object"},"ScenarioTemplate":{"description":"ScenarioTemplate mirrors the catalog shape served by\n GET /api/scenario/v1/list-scenario-templates. The authoritative template\n registry lives in server/worldmonitor/supply-chain/v1/scenario-templates.ts.","properties":{"affectedChokepointIds":{"items":{"description":"Chokepoint ids this scenario disrupts. Empty for tariff-shock scenarios\n that have no physical chokepoint closure.","type":"string"},"type":"array"},"affectedHs2":{"items":{"description":"HS2 chapter codes affected. Empty means ALL sectors are affected.","type":"string"},"type":"array"},"costShockMultiplier":{"description":"Freight cost multiplier applied on top of bypass corridor costs.","format":"double","type":"number"},"disruptionPct":{"description":"0-100 percent of chokepoint capacity blocked.","format":"int32","type":"integer"},"durationDays":{"description":"Estimated duration of disruption in days.","format":"int32","type":"integer"},"id":{"type":"string"},"name":{"type":"string"}},"type":"object"},"ValidationError":{"description":"ValidationError is returned when request validation fails. It contains a list of field violations describing what went wrong.","properties":{"violations":{"description":"List of validation violations","items":{"$ref":"#/components/schemas/FieldViolation"},"type":"array"}},"required":["violations"],"type":"object"}}},"info":{"title":"ScenarioService API","version":"1.0.0"},"openapi":"3.1.0","paths":{"/api/scenario/v1/get-scenario-status":{"get":{"description":"GetScenarioStatus polls a single job's result. PRO-gated.\n Returns status=\"pending\" when no result key exists, mirroring the\n worker's lifecycle state once the key is written.","operationId":"GetScenarioStatus","parameters":[{"description":"Job id of the form `scenario:{epoch_ms}:{8-char-suffix}`. Path-traversal\n guarded by JOB_ID_RE in the handler.","in":"query","name":"jobId","required":false,"schema":{"type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetScenarioStatusResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"GetScenarioStatus","tags":["ScenarioService"]}},"/api/scenario/v1/list-scenario-templates":{"get":{"description":"ListScenarioTemplates returns the catalog of pre-defined scenarios.\n Not PRO-gated — used by documented public API consumers.","operationId":"ListScenarioTemplates","responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListScenarioTemplatesResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"ListScenarioTemplates","tags":["ScenarioService"]}},"/api/scenario/v1/run-scenario":{"post":{"description":"RunScenario enqueues a scenario job on scenario-queue:pending. PRO-gated.\n The scenario-worker (scripts/scenario-worker.mjs) pulls jobs off the\n queue via BLMOVE and writes results under scenario-result:{job_id}.","operationId":"RunScenario","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RunScenarioRequest"}}},"required":true},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RunScenarioResponse"}}},"description":"Successful response"},"400":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ValidationError"}}},"description":"Validation error"},"default":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}},"description":"Error response"}},"summary":"RunScenario","tags":["ScenarioService"]}}}} |