diff --git a/src/apps/operator-dashboard.mjs b/src/apps/operator-dashboard.mjs index 0d09d5c..14fdefd 100644 --- a/src/apps/operator-dashboard.mjs +++ b/src/apps/operator-dashboard.mjs @@ -15,6 +15,7 @@ import { createDashboardLiveState, listDashboardServices, resolveDashboardControl, + resolveDashboardControlTimeoutMs, } from '../core/operator-dashboard.mjs'; import { buildDashboardAuthChallengeHeader, @@ -524,7 +525,7 @@ async function invokeControl(control, body) { 'content-type': 'application/json', }, body: JSON.stringify(body || {}), - signal: AbortSignal.timeout(config.operatorDashboardUpstreamTimeoutMs), + signal: AbortSignal.timeout(resolveDashboardControlTimeoutMs({ control, config })), }, ); diff --git a/src/core/operator-dashboard.mjs b/src/core/operator-dashboard.mjs index 54785fd..7570c7d 100644 --- a/src/core/operator-dashboard.mjs +++ b/src/core/operator-dashboard.mjs @@ -260,6 +260,27 @@ export function buildDashboardControlErrorResponse(error, { control = null } = { }; } +export function resolveDashboardControlTimeoutMs({ control, config } = {}) { + const baseTimeoutMs = Number(config?.operatorDashboardUpstreamTimeoutMs || 3_000); + if (control?.service !== 'trade-executor') return baseTimeoutMs; + + if (control.action === 'intent-request-preflight') { + return Math.max(baseTimeoutMs, Number(config?.intentRequestQuoteTimeoutMs || 10_000) + 2_000); + } + if (control.action === 'intent-request-submit') { + return Math.max( + baseTimeoutMs, + Number(config?.intentRequestPublishTimeoutMs || 10_000) + + Number(config?.intentRequestStatusTimeoutMs || 10_000) + + 5_000, + ); + } + if (control.action === 'intent-request-refresh-outcomes') { + return Math.max(baseTimeoutMs, Number(config?.intentRequestStatusTimeoutMs || 10_000) + 5_000); + } + return baseTimeoutMs; +} + export function listDashboardServices(config) { return SERVICE_DEFINITIONS.map(([service, label, configKey]) => ({ service, diff --git a/src/lib/postgres.mjs b/src/lib/postgres.mjs index 21211ab..69f68e2 100644 --- a/src/lib/postgres.mjs +++ b/src/lib/postgres.mjs @@ -1363,6 +1363,8 @@ function humanizeIntentRequestReason(reason) { 'Relay reported the intent as not found or not valid.', relay_settled_without_expected_inventory_delta: 'Relay reports settlement, but durable inventory does not show the expected EURe decrease and BTC increase.', + solver_quote_unanswered: + 'The relay returned no solver quotes for this request.', }; return labels[normalized] || normalized.replaceAll('_', ' '); } diff --git a/test/operator-dashboard.test.mjs b/test/operator-dashboard.test.mjs index 9f986e9..2dcd9e3 100644 --- a/test/operator-dashboard.test.mjs +++ b/test/operator-dashboard.test.mjs @@ -10,6 +10,7 @@ import { createDashboardLiveState, deriveQuoteLifecycleRows, resolveDashboardControl, + resolveDashboardControlTimeoutMs, } from '../src/core/operator-dashboard.mjs'; import { buildDashboardSessionToken, @@ -105,6 +106,31 @@ test('profitability summary flags cash-flow-adjusted benchmarks after later fund assert.match(summary.caveats[0], /external cash flows/); }); +test('request creation controls get enough upstream time for relay quote and status waits', () => { + const preflight = resolveDashboardControl({ + service: 'trade-executor', + action: 'intent-request-preflight', + }); + const submit = resolveDashboardControl({ + service: 'trade-executor', + action: 'intent-request-submit', + }); + const refresh = resolveDashboardControl({ + service: 'inventory-sync', + action: 'refresh', + }); + const config = { + operatorDashboardUpstreamTimeoutMs: 3000, + intentRequestQuoteTimeoutMs: 10000, + intentRequestPublishTimeoutMs: 10000, + intentRequestStatusTimeoutMs: 10000, + }; + + assert.equal(resolveDashboardControlTimeoutMs({ control: preflight, config }), 12000); + assert.equal(resolveDashboardControlTimeoutMs({ control: submit, config }), 25000); + assert.equal(resolveDashboardControlTimeoutMs({ control: refresh, config }), 3000); +}); + test('dashboard control errors become structured responses instead of uncaught failures', () => { const control = resolveDashboardControl({ service: 'trade-executor', diff --git a/test/postgres-intent-requests.test.mjs b/test/postgres-intent-requests.test.mjs index 6b4f586..1c8f90e 100644 --- a/test/postgres-intent-requests.test.mjs +++ b/test/postgres-intent-requests.test.mjs @@ -115,3 +115,40 @@ test('intent request status refresh loader normalizes accepted relay submissions assert.equal(row.submitted_at, '2026-04-12T16:45:43.133Z'); assert.equal(row.status_checked_at, '2026-04-12T16:45:44.000Z'); }); + + +test('intent request normalization explains unanswered solver quotes in plain terms', () => { + const row = normalizeIntentRequestRow({ + preflight_observed_at: '2026-04-12T17:20:19.476Z', + preflight_ingested_at: '2026-04-12T17:20:19.476Z', + preflight_payload: { + request_id: 'request-unanswered', + idempotency_key: 'intent-request:request-unanswered', + state: 'blocked', + reason_code: 'solver_quote_unanswered', + reason_text: 'The relay returned no solver quotes for this request.', + source_asset_id: 'nep141:eure.omft.near', + source_symbol: 'EURe', + source_decimals: 18, + destination_asset_id: 'nep141:btc.omft.near', + destination_symbol: 'BTC', + destination_decimals: 8, + source_amount_units: '5000000000000000000', + min_destination_amount_units: '8090', + solver_quote_count: 0, + created_at: '2026-04-12T17:20:19.476Z', + }, + outcome_payload: { + request_id: 'request-unanswered', + outcome_status: 'blocked', + outcome_reason: 'solver_quote_unanswered', + attribution_status: 'unattributed', + attributed_inventory_delta: null, + }, + }); + + assert.equal(row.state, 'blocked'); + assert.equal(row.reason_code, 'solver_quote_unanswered'); + assert.equal(row.reason_text, 'The relay returned no solver quotes for this request.'); + assert.equal(row.live_submit_capable, false); +});