feat: unify autopilot launch on server preflight with live balance polling

- 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).
This commit is contained in:
tinkle-community
2026-07-05 20:38:51 +09:00
parent b17d97effc
commit a744328313
14 changed files with 996 additions and 400 deletions

View File

@@ -303,6 +303,24 @@ export function TerminalDashboard({
navSlot,
)}
<div className="tm-box" style={{ maxWidth: 1280, margin: '0 auto', border: 'none' }}>
{/* 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') && (
<div className="tm-mono" style={{ display: 'flex', gap: 10, alignItems: 'center', margin: '8px 14px 0', padding: '8px 12px', fontSize: 11, border: '1px solid var(--tm-down)', color: 'var(--tm-down)', background: 'rgba(200,60,40,0.06)', flexWrap: 'wrap' }}>
<span style={{ fontWeight: 600 }}>
{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.'}
</span>
<span style={{ color: 'var(--tm-ink-2)' }}>
{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 || ''}
</span>
</div>
)}
{/* config / identity strip — first row, flows directly under the global nav */}
<div className="tm-mono" style={{ display: 'flex', gap: 16, padding: '6px 14px', fontSize: 11, color: 'var(--tm-ink-2)', flexWrap: 'wrap' }}>
<span style={{ fontWeight: 500 }}>{selectedTrader?.trader_name ?? 'NOFX'}</span>

View File

@@ -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
}

View File

@@ -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<LaunchPreflightResult | null>(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 ? (
<div className="flex flex-wrap items-center gap-3">
@@ -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 (
<button
type="button"
onClick={launchAutopilot}
onClick={() => void handleLaunch()}
disabled={launching || !allReady}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-nofx-gold px-4 py-3 text-sm font-bold text-white hover:bg-nofx-accent disabled:cursor-not-allowed disabled:opacity-60"
>
@@ -553,7 +568,10 @@ export function AutopilotLaunchPanel({
}
return (
<section className="overflow-hidden rounded-xl border border-nofx-gold/20 bg-nofx-bg-lighter">
<section
id="autopilot-launch-panel"
className="overflow-hidden rounded-xl border border-nofx-gold/20 bg-nofx-bg-lighter"
>
<div className="grid gap-0 xl:grid-cols-[1.05fr_0.95fr]">
<div className="p-5 md:p-6">
<div className="mb-5 flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">