diff --git a/src/core/operator-dashboard.mjs b/src/core/operator-dashboard.mjs index 7040fda..d5f1f1e 100644 --- a/src/core/operator-dashboard.mjs +++ b/src/core/operator-dashboard.mjs @@ -131,7 +131,7 @@ const CONTROL_DEFINITIONS = [ method: 'POST', path: '/arm', label: 'Arm Executor', - description: 'Persistently arm trade-executor so actionable decisions can submit.', + description: 'Persistently arm trade-executor so strategy-approved decisions can submit.', page: 'system', risk_class: 'safe', }, @@ -736,6 +736,7 @@ const HUMAN_REASON_TEXT = { quote_response_ok: 'Quote response accepted by the relay.', reason_unknown: 'Reason not recorded.', stale_reference_price: 'Reference price is stale.', + strategy_approved: 'Strategy approved the quote.', strategy_disarmed: 'Strategy is disarmed.', submission_failed: 'Submission failed.', unsupported_pair: 'Unsupported pair.', @@ -910,7 +911,7 @@ function finalizeLifecycleRow(row) { } else if (decision?.decision) { lifecycle_state = 'evaluated'; lifecycle_label = 'Approved by strategy'; - reason_code = normalizeLifecycleToken(decision?.decision_reason || 'actionable'); + reason_code = normalizeLifecycleToken(decision?.decision_reason || 'strategy_approved'); reason_text = 'Strategy approved the quote, but no durable execute command is recorded yet.'; } @@ -1206,6 +1207,7 @@ function normalizeTradeForUi({ config, trade }) { return { ...trade, + decision_reason: normalizeDecisionReason(trade.decision_reason), asset_in_symbol: assetIn?.symbol || trade.asset_in, asset_out_symbol: assetOut?.symbol || trade.asset_out, amount_in_display: formatUnits(trade.amount_in || '0', assetIn?.decimals || 0), @@ -1341,8 +1343,8 @@ function normalizeDecision(decision) { pair: decision.pair || null, direction: decision.direction || null, request_kind: decision.request_kind || null, - decision: decision.decision || null, - decision_reason: decision.decision_reason || null, + decision: normalizeDecisionVerdict(decision.decision), + decision_reason: normalizeDecisionReason(decision.decision_reason), gross_edge_pct: decision.gross_edge_pct || null, threshold_pct: decision.threshold_pct || null, max_notional_eure: decision.max_notional_eure || null, @@ -1352,6 +1354,16 @@ function normalizeDecision(decision) { }; } +function normalizeDecisionVerdict(value) { + if (value === 'actionable') return 'approved'; + return value || null; +} + +function normalizeDecisionReason(value) { + if (value === 'actionable') return 'strategy_approved'; + return value || null; +} + function normalizeAlertList(alerts) { return (alerts || []).map(normalizeAlert).sort((left, right) => sortTimestamps( right.raised_at || right.first_raised_at || right.cleared_at, diff --git a/test/operator-dashboard.test.mjs b/test/operator-dashboard.test.mjs index 27ead4f..b096c1b 100644 --- a/test/operator-dashboard.test.mjs +++ b/test/operator-dashboard.test.mjs @@ -299,6 +299,7 @@ test('strategy approval no longer renders the forbidden actionable label', () => assert.equal(rows[0].lifecycle_state, 'evaluated'); assert.equal(rows[0].lifecycle_label, 'Approved by strategy'); assert.notEqual(rows[0].lifecycle_label, 'Actionable'); + assert.equal(rows[0].reason_code, 'strategy_approved'); }); test('bootstrap aggregation keeps Funds as default and carries live control state', () => { @@ -496,6 +497,71 @@ test('bootstrap aggregation keeps Funds as default and carries live control stat assert.equal(bootstrap.strategy.strategy_state.recent_lifecycle_rows[0].reason_code, 'strategy_disarmed'); }); +test('bootstrap normalizes actionable decision vocabulary before exposing it to the dashboard', () => { + const config = buildConfig(); + const bootstrap = buildDashboardBootstrap({ + config, + auth: { authenticated: true, subject: 'local-operator', mode: 'stub', roles: ['operator'] }, + portfolioMetric: null, + inventorySnapshot: null, + marketPrice: null, + recentQuotes: [], + submissionPage: { + page: 1, + page_size: 20, + total: 1, + total_pages: 1, + items: [{ + quote_id: 'quote-1', + pair: config.activePair, + observed_at: '2026-04-04T09:10:03.000Z', + status: 'submitted', + result_code: 'quote_response_ok', + decision_reason: 'actionable', + }], + }, + submissionSummary: { + total: 1, + last_submission_at: '2026-04-04T09:10:03.000Z', + }, + fundingObservations: [], + recentDepositStatuses: [], + recentTradeDecisions: [{ + observed_at: '2026-04-04T09:10:00.000Z', + payload: { + decision_id: 'decision-1', + quote_id: 'quote-1', + pair: config.activePair, + decision: 'actionable', + decision_reason: 'actionable', + }, + }], + recentExecuteTradeCommands: [{ + observed_at: '2026-04-04T09:10:01.000Z', + command_id: 'cmd-1', + decision_id: 'decision-1', + quote_id: 'quote-1', + pair: config.activePair, + }], + recentExecutionResults: [{ + command_id: 'cmd-1', + decision_id: 'decision-1', + quote_id: 'quote-1', + pair: config.activePair, + result_at: '2026-04-04T09:10:03.000Z', + status: 'submitted', + result_code: 'quote_response_ok', + }], + recentAlertTransitions: [], + serviceSnapshots: [], + }); + + assert.equal(bootstrap.funds.submission_ledger.items[0].decision_reason, 'strategy_approved'); + assert.equal(bootstrap.strategy.strategy_state.recent_decisions[0].decision, 'approved'); + assert.equal(bootstrap.strategy.strategy_state.recent_lifecycle_rows[0].reason_code, 'quote_response_ok'); + assert.doesNotMatch(JSON.stringify(bootstrap), /Actionable/); +}); + test('system service health uses sentinel-derived severity so stale ingest is never shown healthy', () => { const config = buildConfig(); const bootstrap = buildDashboardBootstrap({