From a4a60fd5219d0fca0786dda3d2f854ef6b9e9a09 Mon Sep 17 00:00:00 2001 From: philipp Date: Sun, 12 Apr 2026 19:22:24 +0200 Subject: [PATCH] Let request controls outlive quote waits Proof: Live dashboard preflight waited through the generic 3s proxy timeout while trade-executor later recorded solver_quote_unanswered after the 10s relay quote wait. Request controls now use action-aware timeouts and unanswered requests render with plain reason text. Assumptions: Own-request preflight needs at least quote_timeout plus small overhead; submit needs publish plus relay-status wait. Generic service refresh controls should keep the shorter dashboard upstream timeout. Still fake: This does not create external solver liquidity; it only lets the dashboard observe whether the request was answered, submitted, or blocked without timing out first. --- src/apps/operator-dashboard.mjs | 3 ++- src/core/operator-dashboard.mjs | 21 +++++++++++++++ src/lib/postgres.mjs | 2 ++ test/operator-dashboard.test.mjs | 26 ++++++++++++++++++ test/postgres-intent-requests.test.mjs | 37 ++++++++++++++++++++++++++ 5 files changed, 88 insertions(+), 1 deletion(-) 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); +});