From a7443283133d4c9ccb48cf926950319e3b1e64d6 Mon Sep 17 00:00:00 2001 From: tinkle-community Date: Sun, 5 Jul 2026 20:38:51 +0900 Subject: [PATCH] feat: unify autopilot launch on server preflight with live balance polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New shared launch module (web/src/lib/launch): preflight client, model/ exchange resolution, and a single launchAutopilot orchestrator used by both Strategy Studio and the guided launch panel — the two previously duplicated and divergent launch implementations are gone. - Preflight runs BEFORE any strategy mutation, so a failed launch no longer rewrites/activates the strategy as a side effect; readiness and minimums come from the server (live balances) instead of stale client caches. - Guided panel polls preflight every 20s so deposits are detected without manual refresh; step metas show live balances and required minimums; the beginner wallet page polls its balance while the deposit screen is open. - Every failure now routes to a guided fix: hyperliquid-funds gets its own setup anchor (scrolls to the launch panel), start rejections surface the server's preflight reason (including manual restarts from the trader list), and structured error bodies flow through ApiError.errorData. - Terminal dashboard shows a persistent runtime banner when the AI fee wallet runs low/empty or the trader enters safe mode. - Vitest coverage for setup-target routing, readiness helpers, and the launch orchestration order (no side effects before preflight). --- .../components/terminal/TerminalDashboard.tsx | 18 + web/src/components/trader/AITradersPage.tsx | 20 +- .../trader/AutopilotLaunchPanel.tsx | 204 ++++++----- web/src/lib/api/traders.ts | 8 +- web/src/lib/httpClient.ts | 9 +- web/src/lib/launch/launch.test.ts | 146 ++++++++ web/src/lib/launch/launchAutopilot.test.ts | 172 +++++++++ web/src/lib/launch/launchAutopilot.ts | 178 ++++++++++ web/src/lib/launch/preflight.ts | 90 +++++ web/src/lib/launch/resolve.ts | 136 +++++++ web/src/lib/launch/types.ts | 44 +++ web/src/pages/BeginnerOnboardingPage.tsx | 28 ++ web/src/pages/StrategyStudioPage.tsx | 336 ++---------------- web/src/types/trading.ts | 7 + 14 files changed, 996 insertions(+), 400 deletions(-) create mode 100644 web/src/lib/launch/launch.test.ts create mode 100644 web/src/lib/launch/launchAutopilot.test.ts create mode 100644 web/src/lib/launch/launchAutopilot.ts create mode 100644 web/src/lib/launch/preflight.ts create mode 100644 web/src/lib/launch/resolve.ts create mode 100644 web/src/lib/launch/types.ts diff --git a/web/src/components/terminal/TerminalDashboard.tsx b/web/src/components/terminal/TerminalDashboard.tsx index ba1f5acc..ca5af174 100644 --- a/web/src/components/terminal/TerminalDashboard.tsx +++ b/web/src/components/terminal/TerminalDashboard.tsx @@ -303,6 +303,24 @@ export function TerminalDashboard({ navSlot, )}
+ {/* runtime health banner — AI fee wallet dry / safe mode would otherwise + only be visible in server logs while the bot silently idles */} + {!on && status && (status.safe_mode || status.ai_wallet_status === 'empty' || status.ai_wallet_status === 'low') && ( +
+ + {status.ai_wallet_status === 'empty' + ? 'AI fee wallet is out of USDC — decisions are failing.' + : status.ai_wallet_status === 'low' + ? `AI fee wallet is low (${(status.ai_wallet_balance_usdc ?? 0).toFixed(2)} USDC) — top up soon.` + : 'Safe mode: AI failed repeatedly, no new positions are being opened.'} + + + {status.ai_wallet_status === 'empty' || status.ai_wallet_status === 'low' + ? 'Deposit Base USDC to the Claw402 wallet, the trader recovers automatically.' + : status.safe_mode_reason || ''} + +
+ )} {/* config / identity strip — first row, flows directly under the global nav */}
{selectedTrader?.trader_name ?? 'NOFX'} diff --git a/web/src/components/trader/AITradersPage.tsx b/web/src/components/trader/AITradersPage.tsx index 06ae9686..41f3a397 100644 --- a/web/src/components/trader/AITradersPage.tsx +++ b/web/src/components/trader/AITradersPage.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react' import { useNavigate, useSearchParams } from 'react-router-dom' import useSWR from 'swr' import { api } from '../../lib/api' +import { ApiError } from '../../lib/httpClient' import type { TraderInfo, CreateTraderRequest, @@ -305,7 +306,15 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { await mutateTraders() } catch (error) { - console.error('Failed to toggle trader:', error) + // Launch preflight rejections carry an actionable reason (e.g. "AI fee + // wallet is empty") — show it instead of a generic failure toast. + if ( + error instanceof ApiError && + error.errorKey === 'trader.start.preflight_failed' + ) { + toast.error(error.message) + return + } toast.error(t('operationFailed', language)) } } @@ -662,6 +671,15 @@ export function AITradersPage({ onTraderSelect }: AITradersPageProps) { handleOpenClaw402Config() } else if (setupTarget === 'hyperliquid') { handleOpenHyperliquidConfig() + } else if (setupTarget === 'hyperliquid-funds') { + // Funding shortfall: bring the guided launch panel (with the trading + // balance step and live preflight polling) into view. + document + .getElementById('autopilot-launch-panel') + ?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + toast.info( + 'Deposit USDC to your Hyperliquid account, the balance check updates automatically.' + ) } else { return } diff --git a/web/src/components/trader/AutopilotLaunchPanel.tsx b/web/src/components/trader/AutopilotLaunchPanel.tsx index 640d45fa..5b76458e 100644 --- a/web/src/components/trader/AutopilotLaunchPanel.tsx +++ b/web/src/components/trader/AutopilotLaunchPanel.tsx @@ -18,6 +18,12 @@ import { import { toast } from 'sonner' import { api } from '../../lib/api' import { buildDashboardPath, ROUTES } from '../../router/paths' +import { + ensureClaw402Strategy, + launchAutopilot, +} from '../../lib/launch/launchAutopilot' +import { runLaunchPreflight } from '../../lib/launch/preflight' +import type { LaunchPreflightResult } from '../../lib/launch/types' import type { AIModel, CurrentBeginnerWalletResponse, @@ -219,13 +225,6 @@ export function AutopilotLaunchPanel({ [models] ) - const feeWalletAddress = claw402Model?.walletAddress || wallet?.address || '' - const feeWalletBalance = parseNumber( - claw402Model?.balanceUsdc || wallet?.balance_usdc - ) - const feeReady = - Boolean(feeWalletAddress) && feeWalletBalance >= MIN_AI_FEE_USDC - const hyperliquidExchange = useMemo( () => exchanges.find( @@ -238,17 +237,80 @@ export function AutopilotLaunchPanel({ [exchanges] ) + // Any hyperliquid account (even partially configured) is enough for the + // server preflight — it reports exactly which prerequisite is missing. + const preflightExchange = useMemo( + () => + hyperliquidExchange || + exchanges.find((exchange) => exchange.exchange_type === 'hyperliquid') || + null, + [exchanges, hyperliquidExchange] + ) + + // Server-side preflight is the source of truth for balances: it queries the + // chain / exchange live (30s server cache) instead of trusting the balance + // snapshot cached in the model object. Poll while the panel is visible so + // deposits show up without a manual refresh. + const [preflight, setPreflight] = useState(null) + const claw402ModelId = claw402Model?.id + const preflightExchangeId = preflightExchange?.id + useEffect(() => { + if (!isLoggedIn || !claw402ModelId || !preflightExchangeId) { + setPreflight(null) + return + } + let cancelled = false + const check = async () => { + try { + const result = await runLaunchPreflight({ + ai_model_id: claw402ModelId, + exchange_id: preflightExchangeId, + }) + if (!cancelled) setPreflight(result) + } catch { + // keep the last known result; client-derived fallbacks still render + } + } + void check() + const timer = setInterval(() => void check(), 20000) + return () => { + cancelled = true + clearInterval(timer) + } + }, [isLoggedIn, claw402ModelId, preflightExchangeId]) + + const preflightCheck = (id: string) => + preflight?.checks.find((check) => check.id === id) + + const feeWalletAddress = + claw402Model?.walletAddress || + wallet?.address || + preflightCheck('ai_wallet')?.address || + '' + const feeFundsCheck = preflightCheck('ai_wallet_funds') + const feeWalletBalance = + feeFundsCheck?.actual ?? + parseNumber(claw402Model?.balanceUsdc || wallet?.balance_usdc) + const minAIFeeUSDC = preflight?.min_ai_fee_usdc ?? MIN_AI_FEE_USDC + const feeReady = feeFundsCheck + ? feeFundsCheck.status !== 'failed' && Boolean(feeWalletAddress) + : Boolean(feeWalletAddress) && feeWalletBalance >= minAIFeeUSDC + const hyperliquidConnected = Boolean(hyperliquidExchange) const exchangeState = hyperliquidExchange ? exchangeAccountStates[hyperliquidExchange.id] : undefined - const tradingBalance = parseNumber( - exchangeState?.available_balance ?? exchangeState?.total_equity - ) + const accountCheck = preflightCheck('exchange_account') + const tradingFundsCheck = preflightCheck('exchange_funds') + const tradingBalance = + tradingFundsCheck?.actual ?? + parseNumber(exchangeState?.available_balance ?? exchangeState?.total_equity) + const minTradingUSDC = preflight?.min_trading_usdc ?? MIN_TRADING_USDC const tradingBalanceReady = hyperliquidConnected && - exchangeState?.status === 'ok' && - tradingBalance >= MIN_TRADING_USDC + (accountCheck && tradingFundsCheck + ? accountCheck.status === 'ok' && tradingFundsCheck.status !== 'failed' + : exchangeState?.status === 'ok' && tradingBalance >= minTradingUSDC) const autopilotTrader = useMemo( () => @@ -286,90 +348,39 @@ export function AutopilotLaunchPanel({ } } - const ensureClaw402Strategy = async () => { - const strategies = await api.getStrategies() - const existing = - strategies.find( - (strategy) => - strategy.is_active && - strategy.config?.ai_config?.coin_source?.source_type === - 'vergex_signal' - ) || - strategies.find((strategy) => - strategy.name.toLowerCase().includes('claw402') - ) - - if (existing) { - if (!existing.is_active) { - await api.activateStrategy(existing.id) - } - return existing.id - } - - const config = await api.getDefaultStrategyConfig() - const created = await api.createStrategy({ - name: 'NOFX Claw402 Auto Strategy', - description: - 'Single built-in strategy: Claw402 board, per-symbol details, raw candles, then execution.', - config, - }) - if (created?.id) { - await api.activateStrategy(created.id) - return created.id - } - - const refreshed = await api.getStrategies() - const fallback = refreshed.find((strategy) => - strategy.name.toLowerCase().includes('claw402') - ) - if (!fallback) throw new Error('Failed to create Claw402 strategy') - await api.activateStrategy(fallback.id) - return fallback.id - } - - const launchAutopilot = async () => { + const handleLaunch = async () => { if (!claw402Model || !hyperliquidExchange) return setLaunching(true) try { - // Re-fetch the live trader list before deciding. The `traders` prop can be - // stale (open tab serving an old snapshot), which would make us create a - // duplicate "NOFX Autopilot" — paying the ~35s first-create cost again and - // orphaning the dashboard onto a deleted id (the "API Not Found" 404). Match - // the same way the autopilotTrader memo does, falling back to it on failure. - let trader = autopilotTrader - try { - const fresh = await api.getTraders(true) - trader = - fresh.find((t) => t.trader_name === 'NOFX Autopilot') || - fresh.find((t) => - (t.strategy_name || '').toLowerCase().includes('claw402') - ) || - trader - } catch { - // network hiccup — keep the prop-derived trader rather than risk a dup + // Shared launch path (same as Strategy Studio): server preflight with + // fresh balances first, then strategy provisioning, then create/start. + const outcome = await launchAutopilot({ + ensureStrategy: ensureClaw402Strategy, + scanIntervalMinutes: 5, + }) + + if (!outcome.ok) { + toast.error(outcome.message) + if (outcome.kind === 'preflight') { + setPreflight(outcome.preflight) + } + if (outcome.kind !== 'error') { + if (outcome.setupTarget === 'claw402') { + onOpenClaw402Config?.() + } else if (outcome.setupTarget === 'hyperliquid') { + onOpenHyperliquidConfig?.() + } + } + await refreshEverything() + return } - if (!trader) { - const strategyId = await ensureClaw402Strategy() - trader = await api.createTrader({ - name: 'NOFX Autopilot', - ai_model_id: claw402Model.id, - exchange_id: hyperliquidExchange.id, - strategy_id: strategyId, - scan_interval_minutes: 5, - is_cross_margin: true, - show_in_competition: true, - btc_eth_leverage: 10, - altcoin_leverage: 10, - }) - } - if (!trader.is_running) { - await api.startTrader(trader.trader_id) + + if (outcome.warning) { + toast.warning(outcome.warning) } await onRefresh() toast.success('NOFX Autopilot is running') - navigate(buildDashboardPath(trader.trader_id)) - } catch (err) { - toast.error(err instanceof Error ? err.message : 'Launch failed') + navigate(buildDashboardPath(outcome.traderId)) } finally { setLaunching(false) } @@ -388,7 +399,9 @@ export function AutopilotLaunchPanel({ 'Pays Claw402.ai data and model calls with Base USDC. This is separate from trading collateral.', status: feeReady ? 'ready' : 'action', meta: feeWalletAddress - ? `${shortAddress(feeWalletAddress)} · ${formatUSDC(feeWalletBalance)} USDC` + ? `${shortAddress(feeWalletAddress)} · ${formatUSDC(feeWalletBalance)} USDC${ + feeReady ? '' : ` · needs ≥ ${minAIFeeUSDC} USDC` + }` : 'Base USDC wallet required', action: feeWalletAddress ? (
@@ -449,7 +462,9 @@ export function AutopilotLaunchPanel({ ? 'action' : 'blocked', meta: hyperliquidConnected - ? `${formatUSDC(tradingBalance)} USDC available` + ? `${formatUSDC(tradingBalance)} USDC available${ + tradingBalanceReady ? '' : ` · needs ≥ ${minTradingUSDC} USDC` + }` : 'Connect Hyperliquid first', }, { @@ -538,7 +553,7 @@ export function AutopilotLaunchPanel({ return (