From 105a3094b13b31a228f428dd636869aeb9f25464 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 13:08:58 +0000 Subject: [PATCH] refactor: remove the trading bot status integration With the sidebar trading indicator gone, the only remaining consumer of the trading bot status was a banner in General settings that reused the same component. evOo is a personal assistant; a status proxy and poller for one external trading service, configured through service-specific TRADING_BOT_URL and VITE_TRADING_BOT_URL variables, does not belong in the core product. Remove the settings banner and its prop plumbing, the web poller, the indicator component, the /api/trading-bot/status proxy, the shared core contract, the env loading and both variables from .env.example, together with their tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- .env.example | 9 - apps/api/src/app.ts | 8 - apps/api/src/env.test.ts | 10 - apps/api/src/env.ts | 4 - apps/api/src/trading-bot-proxy.test.ts | 96 ------ apps/api/src/trading-bot-proxy.ts | 79 ----- .../components/TradingModeIndicator.test.tsx | 55 ---- .../src/components/TradingModeIndicator.tsx | 95 ------ apps/web/src/lib/trading-bot-status.test.ts | 178 ----------- apps/web/src/lib/trading-bot-status.ts | 243 -------------- apps/web/src/pages/AccountSettingsOverlay.tsx | 21 -- apps/web/src/pages/SettingsOverlay.tsx | 4 - apps/web/src/pages/Shell.tsx | 3 - apps/web/src/vite-env.d.ts | 2 - packages/core/src/index.ts | 1 - packages/core/src/trading-bot-status.test.ts | 141 --------- packages/core/src/trading-bot-status.ts | 297 ------------------ 17 files changed, 1246 deletions(-) delete mode 100644 apps/api/src/trading-bot-proxy.test.ts delete mode 100644 apps/api/src/trading-bot-proxy.ts delete mode 100644 apps/web/src/components/TradingModeIndicator.test.tsx delete mode 100644 apps/web/src/components/TradingModeIndicator.tsx delete mode 100644 apps/web/src/lib/trading-bot-status.test.ts delete mode 100644 apps/web/src/lib/trading-bot-status.ts delete mode 100644 packages/core/src/trading-bot-status.test.ts delete mode 100644 packages/core/src/trading-bot-status.ts diff --git a/.env.example b/.env.example index 43a9299..3565c80 100644 --- a/.env.example +++ b/.env.example @@ -210,15 +210,6 @@ RAKAZO_UPDATER_IMAGE_TAG=local RAKAZO_UPDATER_URL= RAKAZO_UPDATER_TOKEN= -# Optional Tradingbot origin (scheme, host, and port only — no path or credentials). -# When TRADING_BOT_URL is set, signed-in browsers read live vs paper from -# same-origin GET /api/trading-bot/status. Leave unset for SaaS; evOo runs without a bot. -# TRADING_BOT_URL=http://127.0.0.1:8000 -# Optional build-time override. When set, the web app polls -# ${VITE_TRADING_BOT_URL}/bot/api/status directly. That call is cross-origin, so the -# bot must allow this web origin. Leave unset to use the API proxy above. -# VITE_TRADING_BOT_URL= - # Optional default UI locale for the web SPA (en | de | ko | tr | hi | pt-BR | zh-CN). # Overridden by localStorage key rakazo.uiLocale when the user picks a language in Settings. # VITE_DEFAULT_UI_LOCALE=en diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 948d735..05ee689 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -109,7 +109,6 @@ import { settleWithTimeout, TEAM_CHAT_STARTUP_SHUTDOWN_MS, } from "./team-chat-startup.js"; -import { mountTradingBotStatus } from "./trading-bot-proxy.js"; import { mountVoiceHttpRoutes } from "./voice.js"; import { mountWebhookHttpRoutes } from "./webhook.js"; @@ -530,13 +529,6 @@ export async function createApp( resetUrl: email ? new URL("/reset-password", env.webOrigin).href : null, }), ); - mountTradingBotStatus(app, { - baseUrl: env.tradingBotUrl, - authorize: async (request) => { - const session = await auth.api.getSession({ headers: sessionHeaders(request) }); - return Boolean(session?.user); - }, - }); if (localEmailEmulator && env.nodeEnv === "development") { app.get( "/api/dev/emails", diff --git a/apps/api/src/env.test.ts b/apps/api/src/env.test.ts index 6e92002..29ecb51 100644 --- a/apps/api/src/env.test.ts +++ b/apps/api/src/env.test.ts @@ -42,16 +42,6 @@ describe("loadEnv", () => { ); }); - it("loads a trading bot origin and refuses credentials", () => { - expect(loadEnv(base).tradingBotUrl).toBeUndefined(); - expect( - loadEnv({ ...base, TRADING_BOT_URL: " http://127.0.0.1:8000/bot/ " }).tradingBotUrl, - ).toBe("http://127.0.0.1:8000"); - expect(() => - loadEnv({ ...base, TRADING_BOT_URL: "http://user:secret@127.0.0.1:8000" }), - ).toThrow(/^Invalid TRADING_BOT_URL$/); - }); - it("loads an optional integrations catalog mirror", () => { expect(loadEnv(base).integrationsCatalogUrl).toBeUndefined(); expect( diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index c88c2da..b260c21 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -8,7 +8,6 @@ import { resolveEncryptionKey, resolveScreenProxySecret, resolveSupervisorToken, - resolveTradingBotOrigin, } from "@rakazo/core"; export { resolveCloudAgentProvider, resolveSandboxProvider } from "@rakazo/adapters"; @@ -93,8 +92,6 @@ export interface AppEnv { updaterToken: string | undefined; /** Current application image tag; used for compose manual-upgrade command selection. */ imageTag: string | undefined; - /** Tradingbot origin for the same-origin status proxy. Unset on SaaS builds. */ - tradingBotUrl: string | undefined; } export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv { @@ -182,7 +179,6 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv { updaterUrl, updaterToken, imageTag: optional(source.RAKAZO_IMAGE_TAG), - tradingBotUrl: resolveTradingBotOrigin(source.TRADING_BOT_URL), }; } diff --git a/apps/api/src/trading-bot-proxy.test.ts b/apps/api/src/trading-bot-proxy.test.ts deleted file mode 100644 index 22e0808..0000000 --- a/apps/api/src/trading-bot-proxy.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { TRADING_BOT_PROXY_PATH } from "@rakazo/core"; -import { Hono } from "hono"; -import { describe, expect, it, vi } from "vitest"; -import { mountTradingBotStatus } from "./trading-bot-proxy.js"; - -function proxyApp(options: { - baseUrl?: string; - authorize?: (request: Request) => Promise; - fetchImpl?: typeof fetch; -}) { - const app = new Hono(); - mountTradingBotStatus(app, { - baseUrl: options.baseUrl, - authorize: options.authorize ?? (async () => true), - fetchImpl: options.fetchImpl, - }); - return app; -} - -describe("trading bot status proxy", () => { - it("stays idle when TRADING_BOT_URL is unset and does not call upstream", async () => { - const fetchImpl = vi.fn(); - const response = await proxyApp({ fetchImpl }).request(TRADING_BOT_PROXY_PATH); - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ configured: false }); - expect(fetchImpl).not.toHaveBeenCalled(); - }); - - it("requires a session before it proxies a configured origin", async () => { - const fetchImpl = vi.fn(); - const response = await proxyApp({ - baseUrl: "http://bot.test:8000", - authorize: async () => false, - fetchImpl, - }).request(TRADING_BOT_PROXY_PATH, { headers: { cookie: "session=secret" } }); - expect(response.status).toBe(401); - expect(fetchImpl).not.toHaveBeenCalled(); - }); - - it("returns the mapped status and does not forward browser credentials", async () => { - const fetchImpl = vi.fn( - async () => - new Response( - JSON.stringify({ mode: "LIVE", adapter_count: 2, last_error: "adapter timeout" }), - { status: 200, headers: { "content-type": "application/json" } }, - ), - ); - const response = await proxyApp({ - baseUrl: "http://bot.test:8000", - fetchImpl, - }).request(TRADING_BOT_PROXY_PATH, { - headers: { cookie: "session=secret", authorization: "Bearer browser-token" }, - }); - expect(response.status).toBe(200); - expect(await response.json()).toEqual({ - configured: true, - reachable: true, - status: { - mode: "live", - kind: "live", - adapterCount: 2, - lastError: "adapter timeout", - }, - }); - expect(fetchImpl).toHaveBeenCalledTimes(1); - const [url, init] = fetchImpl.mock.calls[0] as [string, RequestInit]; - expect(url).toBe("http://bot.test:8000/bot/api/status"); - const headers = new Headers(init.headers); - expect(headers.get("cookie")).toBeNull(); - expect(headers.get("authorization")).toBeNull(); - expect(init.redirect).toBe("error"); - }); - - it("hides an unmapped body and an upstream failure instead of claiming live", async () => { - const unmapped = vi.fn( - async () => - new Response(JSON.stringify({ status: "ok" }), { - status: 200, - headers: { "content-type": "application/json" }, - }), - ); - const unknown = await proxyApp({ - baseUrl: "http://bot.test:8000", - fetchImpl: unmapped, - }).request(TRADING_BOT_PROXY_PATH); - expect(await unknown.json()).toEqual({ configured: true, reachable: true, status: null }); - - const down = vi.fn(async () => new Response("nope", { status: 500 })); - const offline = await proxyApp({ - baseUrl: "http://bot.test:8000", - fetchImpl: down, - }).request(TRADING_BOT_PROXY_PATH); - expect(offline.status).toBe(502); - expect(await offline.json()).toEqual({ configured: true, reachable: false }); - }); -}); diff --git a/apps/api/src/trading-bot-proxy.ts b/apps/api/src/trading-bot-proxy.ts deleted file mode 100644 index 16bd736..0000000 --- a/apps/api/src/trading-bot-proxy.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { - mapTradingBotStatus, - readBoundedJsonResponse, - TRADING_BOT_PROXY_PATH, - TRADING_BOT_STATUS_MAX_BYTES, - TRADING_BOT_STATUS_TIMEOUT_MS, - tradingBotStatusUrl, -} from "@rakazo/core"; -import type { Hono } from "hono"; - -export function mountTradingBotStatus( - app: Hono, - options: { - /** Normalized http(s) origin. Unset leaves the route idle for SaaS builds. */ - baseUrl: string | undefined; - authorize: (request: Request) => Promise; - fetchImpl?: typeof fetch; - }, -) { - const fetchImpl = options.fetchImpl ?? fetch; - app.get(TRADING_BOT_PROXY_PATH, async (c) => { - c.header("cache-control", "no-store"); - if (!options.baseUrl) return c.json({ configured: false }); - const allowed = await options.authorize(c.req.raw).catch(() => false); - if (!allowed) return c.json({ error: "Unauthorized" }, 401); - - const timeout = linkAbort(c.req.raw.signal, TRADING_BOT_STATUS_TIMEOUT_MS); - try { - const response = await fetchImpl(tradingBotStatusUrl(options.baseUrl), { - method: "GET", - redirect: "error", - cache: "no-store", - signal: timeout.signal, - headers: { accept: "application/json" }, - }); - if (!response.ok) { - cancelBody(response); - return c.json({ configured: true, reachable: false }, 502); - } - const body = await readBoundedJsonResponse( - response, - TRADING_BOT_STATUS_MAX_BYTES, - timeout.signal, - ); - const status = mapTradingBotStatus(body); - return c.json({ configured: true, reachable: true, status: status ?? null }); - } catch { - return c.json({ configured: true, reachable: false }, 502); - } finally { - timeout.cancel(); - } - }); -} - -function linkAbort( - parent: AbortSignal, - timeoutMs: number, -): { signal: AbortSignal; cancel: () => void } { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - const onParent = () => controller.abort(); - if (parent.aborted) controller.abort(); - else parent.addEventListener("abort", onParent); - return { - signal: controller.signal, - cancel: () => { - clearTimeout(timer); - parent.removeEventListener("abort", onParent); - }, - }; -} - -function cancelBody(response: Response): void { - try { - void Promise.resolve(response.body?.cancel()).catch(() => undefined); - } catch { - // Best-effort. The caller still reports the bot as unreachable. - } -} diff --git a/apps/web/src/components/TradingModeIndicator.test.tsx b/apps/web/src/components/TradingModeIndicator.test.tsx deleted file mode 100644 index db8bbbc..0000000 --- a/apps/web/src/components/TradingModeIndicator.test.tsx +++ /dev/null @@ -1,55 +0,0 @@ -// @vitest-environment jsdom - -import type { ReactNode } from "react"; -import { act } from "react"; -import { createRoot } from "react-dom/client"; -import { expect, it, vi } from "vitest"; - -vi.mock("@lingui/react/macro", () => ({ - Trans: ({ children }: { children: ReactNode }) => children, -})); - -import { TradingModeIndicator } from "./TradingModeIndicator"; - -it("renders the API mode and ignores a stored live preference", async () => { - vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); - window.localStorage.setItem("evoo_trading_mode", "live"); - const container = document.createElement("div"); - document.body.append(container); - const root = createRoot(container); - try { - await act(async () => - root.render( - , - ), - ); - const status = container.querySelector("[data-testid='trading-mode-status']"); - expect(status?.getAttribute("data-trading-state")).toBe("dry_run"); - expect(status?.textContent).toContain("Dry run"); - expect(status?.textContent).toContain("3 adapters"); - expect(container.querySelector("button")).toBeNull(); - expect(status?.textContent).not.toContain("Live Trading"); - - await act(async () => root.render()); - expect(container.querySelector("[data-testid='trading-mode-status']")?.textContent).toContain( - "Offline", - ); - - await act(async () => - root.render(), - ); - expect(container.querySelector("[data-testid='trading-mode-status']")?.textContent).toContain( - "Not configured", - ); - } finally { - await act(async () => root.unmount()); - container.remove(); - window.localStorage.clear(); - vi.unstubAllGlobals(); - } -}); diff --git a/apps/web/src/components/TradingModeIndicator.tsx b/apps/web/src/components/TradingModeIndicator.tsx deleted file mode 100644 index 6780aa1..0000000 --- a/apps/web/src/components/TradingModeIndicator.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { Trans } from "@lingui/react/macro"; -import { cn } from "@rakazo/ui-web"; -import { Activity } from "lucide-react"; -import type { ReactNode } from "react"; -import { - presentTradingStatus, - type TradingBotUiState, - type TradingModeLabelId, - type TradingModeSubtitle, -} from "../lib/trading-bot-status"; - -export function TradingModeIndicator({ - snapshot, - className, -}: { - snapshot: TradingBotUiState; - className?: string; -}) { - const presentation = presentTradingStatus(snapshot); - return ( -
- - - - - - - {presentation.subtitle ? ( - - - - ) : null} - -
- ); -} - -function TradingModeLabel({ label }: { label: TradingModeLabelId }): ReactNode { - switch (label) { - case "loading": - return Trading; - case "unconfigured": - return Not configured; - case "offline": - return Offline; - case "unknown": - return Unknown; - case "live": - return Live Trading; - case "paper": - return Paper; - case "dry_run": - return Dry run; - default: { - const exhaustive: never = label; - return exhaustive; - } - } -} - -function TradingModeSubtitleText({ subtitle }: { subtitle: TradingModeSubtitle }): ReactNode { - switch (subtitle.type) { - case "error": - return subtitle.text; - case "adapters": - return subtitle.count === 1 ? ( - 1 adapter - ) : ( - {subtitle.count} adapters - ); - default: { - const exhaustive: never = subtitle; - return exhaustive; - } - } -} diff --git a/apps/web/src/lib/trading-bot-status.test.ts b/apps/web/src/lib/trading-bot-status.test.ts deleted file mode 100644 index 95a86aa..0000000 --- a/apps/web/src/lib/trading-bot-status.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { TRADING_BOT_PROXY_PATH } from "@rakazo/core"; -import { describe, expect, it, vi } from "vitest"; -import { - fetchTradingBotSnapshot, - pollTradingBotStatus, - presentTradingStatus, - resolveTradingStatusTarget, -} from "./trading-bot-status"; - -describe("resolveTradingStatusTarget", () => { - it("polls the bot directly only when the Vite URL is a bare origin", () => { - expect(resolveTradingStatusTarget(undefined)).toEqual({ - kind: "proxy", - url: TRADING_BOT_PROXY_PATH, - }); - expect(resolveTradingStatusTarget("")).toEqual({ kind: "proxy", url: TRADING_BOT_PROXY_PATH }); - expect(resolveTradingStatusTarget(" http://100.64.0.1:8000/bot/ ")).toEqual({ - kind: "direct", - url: "http://100.64.0.1:8000/bot/api/status", - }); - expect(resolveTradingStatusTarget("file:///tmp/bot")).toEqual({ - kind: "proxy", - url: TRADING_BOT_PROXY_PATH, - }); - }); -}); - -describe("presentTradingStatus", () => { - it("shows the API mode and never a stored live preference", () => { - expect( - presentTradingStatus({ - state: "ready", - status: { mode: "live", kind: "live", adapterCount: 2, lastError: null }, - }), - ).toMatchObject({ - label: "live", - tone: "live", - pulse: true, - subtitle: { type: "adapters", count: 2 }, - }); - expect( - presentTradingStatus({ - state: "ready", - status: { mode: "paper", kind: "dry_run", adapterCount: 2, lastError: "adapter timeout" }, - }).subtitle, - ).toEqual({ type: "error", text: "adapter timeout" }); - expect(presentTradingStatus({ state: "offline" }).label).toBe("offline"); - expect(presentTradingStatus({ state: "unknown" }).label).toBe("unknown"); - expect(presentTradingStatus({ state: "unconfigured" }).label).toBe("unconfigured"); - expect(presentTradingStatus({ state: "loading" }).label).toBe("loading"); - }); -}); - -describe("fetchTradingBotSnapshot", () => { - it("maps a direct live body and reports an unmapped 200 as unknown", async () => { - const live = await fetchTradingBotSnapshot( - { kind: "direct", url: "http://bot.test/bot/api/status" }, - new AbortController().signal, - async () => jsonResponse({ mode: "LIVE" }), - ); - expect(live).toMatchObject({ state: "ready", status: { mode: "live", kind: "live" } }); - - const unknown = await fetchTradingBotSnapshot( - { kind: "direct", url: "http://bot.test/bot/api/status" }, - new AbortController().signal, - async () => jsonResponse({ status: "ok" }), - ); - expect(unknown).toEqual({ state: "unknown" }); - }); - - it("treats a failed direct fetch as offline", async () => { - const offline = await fetchTradingBotSnapshot( - { kind: "direct", url: "http://bot.test/bot/api/status" }, - new AbortController().signal, - async () => { - throw new Error("network"); - }, - ); - expect(offline).toEqual({ state: "offline" }); - }); - - it("stops on an explicit unconfigured proxy response", async () => { - const fetchImpl = vi.fn(async () => jsonResponse({ configured: false })); - const snapshots: string[] = []; - await pollTradingBotStatus({ - target: { kind: "proxy", url: TRADING_BOT_PROXY_PATH }, - signal: new AbortController().signal, - fetchImpl, - onSnapshot: (snapshot) => snapshots.push(snapshot.state), - sleep: async () => false, - }); - expect(snapshots).toEqual(["unconfigured"]); - expect(fetchImpl).toHaveBeenCalledTimes(1); - }); -}); - -describe("pollTradingBotStatus", () => { - it("follows a live read with offline on failure and backs off", async () => { - let calls = 0; - const fetchImpl = vi.fn(async () => { - calls += 1; - if (calls === 1) return jsonResponse({ badge: "LIVE" }); - throw new Error("down"); - }); - const sleeps: number[] = []; - let releaseSleep: () => void = () => undefined; - const sleep = (ms: number, signal: AbortSignal) => { - sleeps.push(ms); - return new Promise((resolve) => { - if (signal.aborted) { - resolve(true); - return; - } - const onAbort = () => resolve(true); - signal.addEventListener("abort", onAbort, { once: true }); - releaseSleep = () => { - signal.removeEventListener("abort", onAbort); - resolve(false); - }; - }); - }; - const snapshots: string[] = []; - const controller = new AbortController(); - const done = pollTradingBotStatus({ - target: { kind: "direct", url: "http://bot.test/bot/api/status" }, - signal: controller.signal, - fetchImpl, - intervalMs: 20_000, - maxBackoffMs: 120_000, - sleep, - onSnapshot: (snapshot) => snapshots.push(snapshot.state), - }); - await vi.waitFor(() => expect(snapshots).toEqual(["ready"])); - expect(sleeps).toEqual([20_000]); - releaseSleep(); - await vi.waitFor(() => expect(snapshots).toEqual(["ready", "offline"])); - expect(sleeps).toEqual([20_000, 20_000]); - releaseSleep(); - await vi.waitFor(() => expect(sleeps).toEqual([20_000, 20_000, 40_000])); - controller.abort(); - await done; - expect(fetchImpl).toHaveBeenCalledTimes(3); - }); - - it("does not publish offline when the poll is aborted in flight", async () => { - const controller = new AbortController(); - const snapshots: string[] = []; - const fetchImpl: typeof fetch = (_input, init) => - new Promise((_resolve, reject) => { - const abort = () => { - const error = new Error("aborted"); - error.name = "AbortError"; - reject(error); - }; - if (init?.signal?.aborted) { - abort(); - return; - } - init?.signal?.addEventListener("abort", abort, { once: true }); - }); - const done = pollTradingBotStatus({ - target: { kind: "direct", url: "http://bot.test/bot/api/status" }, - signal: controller.signal, - fetchImpl, - onSnapshot: (snapshot) => snapshots.push(snapshot.state), - }); - controller.abort(); - await done; - expect(snapshots).toEqual([]); - }); -}); - -function jsonResponse(body: unknown): Response { - return new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json" }, - }); -} diff --git a/apps/web/src/lib/trading-bot-status.ts b/apps/web/src/lib/trading-bot-status.ts deleted file mode 100644 index 5b04ec0..0000000 --- a/apps/web/src/lib/trading-bot-status.ts +++ /dev/null @@ -1,243 +0,0 @@ -import type { TradingBotSnapshot, TradingBotStatusView } from "@rakazo/core"; -import { - interpretTradingBotProxyResponse, - mapTradingBotStatus, - parseTradingBotOrigin, - readBoundedJsonResponse, - TRADING_BOT_POLL_INTERVAL_MS, - TRADING_BOT_POLL_MAX_BACKOFF_MS, - TRADING_BOT_PROXY_PATH, - TRADING_BOT_STATUS_MAX_BYTES, - TRADING_BOT_STATUS_TIMEOUT_MS, - tradingBotBackoffMs, - tradingBotStatusUrl, -} from "@rakazo/core"; -import { useEffect, useState } from "react"; - -export type TradingStatusTarget = - | { kind: "direct"; url: string } - | { kind: "proxy"; url: typeof TRADING_BOT_PROXY_PATH }; - -export type TradingBotUiState = TradingBotSnapshot | { state: "loading" }; - -export type TradingModeLabelId = - | "loading" - | "unconfigured" - | "offline" - | "unknown" - | "live" - | "paper" - | "dry_run"; - -export type TradingModeSubtitle = - | { type: "adapters"; count: number } - | { type: "error"; text: string }; - -export type TradingModePresentation = { - label: TradingModeLabelId; - tone: "live" | "quiet"; - pulse: boolean; - subtitle: TradingModeSubtitle | null; -}; - -type FetchSnapshot = ( - target: TradingStatusTarget, - signal: AbortSignal, - fetchImpl: typeof fetch, - timeoutMs: number, -) => Promise; - -/** Direct poll when the Vite URL is set. Otherwise the same-origin API proxy. */ -export function resolveTradingStatusTarget(viteUrl: string | undefined): TradingStatusTarget { - const parsed = parseTradingBotOrigin(viteUrl); - if (parsed.kind === "origin") { - return { kind: "direct", url: tradingBotStatusUrl(parsed.origin) }; - } - return { kind: "proxy", url: TRADING_BOT_PROXY_PATH }; -} - -export function presentTradingStatus(snapshot: TradingBotUiState): TradingModePresentation { - switch (snapshot.state) { - case "loading": - return { label: "loading", tone: "quiet", pulse: false, subtitle: null }; - case "unconfigured": - return { label: "unconfigured", tone: "quiet", pulse: false, subtitle: null }; - case "offline": - return { label: "offline", tone: "quiet", pulse: false, subtitle: null }; - case "unknown": - return { label: "unknown", tone: "quiet", pulse: false, subtitle: null }; - case "ready": - return presentReady(snapshot.status); - default: { - const exhaustive: never = snapshot; - return exhaustive; - } - } -} - -export async function fetchTradingBotSnapshot( - target: TradingStatusTarget, - signal: AbortSignal, - fetchImpl: typeof fetch = fetch, - timeoutMs = TRADING_BOT_STATUS_TIMEOUT_MS, -): Promise { - const timeout = linkAbort(signal, timeoutMs); - try { - const response = await fetchImpl(target.url, { - method: "GET", - redirect: "error", - cache: "no-store", - credentials: target.kind === "direct" ? "omit" : "include", - signal: timeout.signal, - headers: { accept: "application/json" }, - }); - if (target.kind === "direct" && !response.ok) { - cancelBody(response); - return { state: "offline" }; - } - let body: unknown = null; - try { - body = await readBoundedJsonResponse( - response, - TRADING_BOT_STATUS_MAX_BYTES, - timeout.signal, - ); - } catch (error) { - if (signal.aborted) throw error; - body = null; - } - if (target.kind === "proxy") return interpretTradingBotProxyResponse(response.status, body); - if (body === null) return { state: "offline" }; - const status = mapTradingBotStatus(body); - return status ? { state: "ready", status } : { state: "unknown" }; - } catch (error) { - if (signal.aborted) throw error; - return { state: "offline" }; - } finally { - timeout.cancel(); - } -} - -export async function pollTradingBotStatus(options: { - target: TradingStatusTarget; - signal: AbortSignal; - onSnapshot: (snapshot: TradingBotSnapshot) => void; - fetchImpl?: typeof fetch; - fetchSnapshot?: FetchSnapshot; - intervalMs?: number; - maxBackoffMs?: number; - timeoutMs?: number; - sleep?: (ms: number, signal: AbortSignal) => Promise; -}): Promise { - const fetchSnapshot = options.fetchSnapshot ?? fetchTradingBotSnapshot; - const fetchImpl = options.fetchImpl ?? fetch; - const intervalMs = options.intervalMs ?? TRADING_BOT_POLL_INTERVAL_MS; - const maxBackoffMs = options.maxBackoffMs ?? TRADING_BOT_POLL_MAX_BACKOFF_MS; - const timeoutMs = options.timeoutMs ?? TRADING_BOT_STATUS_TIMEOUT_MS; - const sleep = options.sleep ?? delayAbortable; - let failures = 0; - while (!options.signal.aborted) { - let snapshot: TradingBotSnapshot; - try { - snapshot = await fetchSnapshot(options.target, options.signal, fetchImpl, timeoutMs); - } catch { - if (options.signal.aborted) return; - snapshot = { state: "offline" }; - } - if (options.signal.aborted) return; - options.onSnapshot(snapshot); - if (snapshot.state === "unconfigured") return; - if (snapshot.state === "offline") failures += 1; - else failures = 0; - const delay = - snapshot.state === "offline" - ? tradingBotBackoffMs(intervalMs, failures, maxBackoffMs) - : intervalMs; - const aborted = await sleep(delay, options.signal); - if (aborted) return; - } -} - -export function useTradingBotStatus(): TradingBotUiState { - const [snapshot, setSnapshot] = useState({ state: "loading" }); - useEffect(() => { - const controller = new AbortController(); - const target = resolveTradingStatusTarget(readViteTradingBotUrl()); - void pollTradingBotStatus({ - target, - signal: controller.signal, - onSnapshot: (next) => { - if (!controller.signal.aborted) setSnapshot(next); - }, - }); - return () => controller.abort(); - }, []); - return snapshot; -} - -function readViteTradingBotUrl(): string | undefined { - const value = import.meta.env.VITE_TRADING_BOT_URL; - return typeof value === "string" ? value : undefined; -} - -function presentReady(status: TradingBotStatusView): TradingModePresentation { - const subtitle: TradingModeSubtitle | null = status.lastError - ? { type: "error", text: status.lastError } - : status.adapterCount === null - ? null - : { type: "adapters", count: status.adapterCount }; - switch (status.kind) { - case "live": - return { label: "live", tone: "live", pulse: true, subtitle }; - case "paper": - return { label: "paper", tone: "quiet", pulse: false, subtitle }; - case "dry_run": - return { label: "dry_run", tone: "quiet", pulse: false, subtitle }; - default: { - const exhaustive: never = status.kind; - return exhaustive; - } - } -} - -function linkAbort( - parent: AbortSignal, - timeoutMs: number, -): { signal: AbortSignal; cancel: () => void } { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - const onParent = () => controller.abort(); - if (parent.aborted) controller.abort(); - else parent.addEventListener("abort", onParent); - return { - signal: controller.signal, - cancel: () => { - clearTimeout(timer); - parent.removeEventListener("abort", onParent); - }, - }; -} - -function delayAbortable(ms: number, signal: AbortSignal): Promise { - if (signal.aborted) return Promise.resolve(true); - return new Promise((resolve) => { - const timer = setTimeout(() => { - signal.removeEventListener("abort", onAbort); - resolve(false); - }, ms); - const onAbort = () => { - clearTimeout(timer); - signal.removeEventListener("abort", onAbort); - resolve(true); - }; - signal.addEventListener("abort", onAbort); - }); -} - -function cancelBody(response: Response): void { - try { - void Promise.resolve(response.body?.cancel()).catch(() => undefined); - } catch { - // Best-effort. The snapshot is already offline. - } -} diff --git a/apps/web/src/pages/AccountSettingsOverlay.tsx b/apps/web/src/pages/AccountSettingsOverlay.tsx index b01606f..d222a72 100644 --- a/apps/web/src/pages/AccountSettingsOverlay.tsx +++ b/apps/web/src/pages/AccountSettingsOverlay.tsx @@ -16,10 +16,8 @@ import { SuccessPop } from "../components/ai/primitives"; import { ComputersUnavailableHint } from "../components/ComputersUnavailableHint"; import { DesktopUpdateSection } from "../components/DesktopUpdates"; import { SoftwareUpdateSection } from "../components/SoftwareUpdateSection"; -import { TradingModeIndicator } from "../components/TradingModeIndicator"; import { authClient } from "../lib/auth"; import { getActiveUiLocale, setUiLocale } from "../lib/i18n"; -import type { TradingBotUiState } from "../lib/trading-bot-status"; import { type AppearancePreference, getUiAppearancePreference, @@ -35,25 +33,8 @@ export type SettingsGeneralProps = { messagingEnabled?: boolean; onOpenMessaging?: () => void; isDeploymentOwner?: boolean; - tradingStatus?: TradingBotUiState; }; -export function LiveTradingBanner({ snapshot }: { snapshot: TradingBotUiState }) { - if (snapshot.state === "loading" || snapshot.state === "unconfigured") return null; - const live = snapshot.state === "ready" && snapshot.status.mode === "live"; - - return ( -
- -
- ); -} - export function GeneralSettingsPanels({ email, name, @@ -62,7 +43,6 @@ export function GeneralSettingsPanels({ messagingEnabled = false, onOpenMessaging, isDeploymentOwner = false, - tradingStatus, }: SettingsGeneralProps) { const { t } = useLingui(); const [locale, setLocale] = useState(() => getActiveUiLocale()); @@ -98,7 +78,6 @@ export function GeneralSettingsPanels({ return (
- {tradingStatus ? : null}

Account diff --git a/apps/web/src/pages/SettingsOverlay.tsx b/apps/web/src/pages/SettingsOverlay.tsx index d71d40a..9240751 100644 --- a/apps/web/src/pages/SettingsOverlay.tsx +++ b/apps/web/src/pages/SettingsOverlay.tsx @@ -17,7 +17,6 @@ import { import { type ComponentType, useEffect, useRef, useState } from "react"; import { computersAreUnavailable } from "../components/ComputersUnavailableHint"; import { UserProfileSettings } from "../components/UserProfileSettings"; -import type { TradingBotUiState } from "../lib/trading-bot-status"; import { ComputerSettingsPanel, GeneralSettingsPanels, @@ -65,7 +64,6 @@ export function SettingsOverlay({ onVoiceStatusMaybeChanged, activeBotId, onOpenMcp, - tradingStatus, }: { email?: string | null; name: string; @@ -83,7 +81,6 @@ export function SettingsOverlay({ onVoiceStatusMaybeChanged?: () => void | Promise; activeBotId?: string; onOpenMcp?: () => void; - tradingStatus?: TradingBotUiState; }) { const { t } = useLingui(); const panelRef = useRef(null); @@ -238,7 +235,6 @@ export function SettingsOverlay({ onOpenMessaging ? () => leaveSettings(onOpenMessaging) : undefined } isDeploymentOwner={isDeploymentOwner} - tradingStatus={tradingStatus} /> ) : null} {section === "integrations" ? ( diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index 4a6c86b..63dad4c 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -237,7 +237,6 @@ import { threadRunError, userHoldsComputerControl, } from "../lib/thread-events"; -import { useTradingBotStatus } from "../lib/trading-bot-status"; import { transcriptCanSnapAfterFrame, transcriptIsNearEnd, @@ -425,7 +424,6 @@ export function ShellPage() { if (botId) return "team"; return "heute"; }); - const tradingStatus = useTradingBotStatus(); useEffect(() => { if (botId) setActiveNavPillar("team"); @@ -5580,7 +5578,6 @@ export function ShellPage() { setSettingsOpen(false); setMessagingSettingsOpen(true); }} - tradingStatus={tradingStatus} onAvatarStyleChange={async (avatarStyle) => { const nextMe = await rpc.preferences.update({ avatarStyle }); setBootstrapMe(nextMe); diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 933aa4a..f69daed 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -2,8 +2,6 @@ interface ImportMetaEnv { readonly VITE_DEFAULT_UI_LOCALE?: string; - /** Optional Tradingbot origin. Empty uses the same-origin API proxy. */ - readonly VITE_TRADING_BOT_URL?: string; } interface ImportMeta { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c870847..e78992f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -45,6 +45,5 @@ export * from "./text-direction.js"; export * from "./thread-message-updates.js"; export * from "./thread-subscription.js"; export * from "./tool-activity.js"; -export * from "./trading-bot-status.js"; export * from "./voice-chat-command.js"; export * from "./voice-labels.js"; diff --git a/packages/core/src/trading-bot-status.test.ts b/packages/core/src/trading-bot-status.test.ts deleted file mode 100644 index 38f0438..0000000 --- a/packages/core/src/trading-bot-status.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - coerceTradingBotStatusView, - interpretTradingBotProxyResponse, - mapTradingBotStatus, - parseTradingBotOrigin, - resolveTradingBotOrigin, - sanitizeTradingBotError, - tradingBotBackoffMs, - tradingBotStatusUrl, -} from "./trading-bot-status.js"; - -describe("parseTradingBotOrigin", () => { - it("treats an empty value as unset and keeps the origin without a path", () => { - expect(parseTradingBotOrigin(undefined)).toEqual({ kind: "unset" }); - expect(parseTradingBotOrigin(" ")).toEqual({ kind: "unset" }); - expect(parseTradingBotOrigin(" http://127.0.0.1:8000/bot/ ")).toEqual({ - kind: "origin", - origin: "http://127.0.0.1:8000", - }); - expect(tradingBotStatusUrl("http://127.0.0.1:8000")).toBe( - "http://127.0.0.1:8000/bot/api/status", - ); - }); - - it("rejects credentials and non-http schemes without echoing the value", () => { - expect(parseTradingBotOrigin("http://user:secret@127.0.0.1:8000").kind).toBe("invalid"); - expect(parseTradingBotOrigin("file:///tmp/status").kind).toBe("invalid"); - expect(parseTradingBotOrigin("not a url").kind).toBe("invalid"); - expect(resolveTradingBotOrigin("")).toBeUndefined(); - expect(() => resolveTradingBotOrigin("javascript:alert(1)")).toThrow( - /^Invalid TRADING_BOT_URL$/, - ); - }); -}); - -describe("mapTradingBotStatus", () => { - it("maps live, paper, and dry-run badges without defaulting to live", () => { - expect(mapTradingBotStatus({ mode: "LIVE" })).toMatchObject({ mode: "live", kind: "live" }); - expect(mapTradingBotStatus({ badge: "DRY RUN" })).toMatchObject({ - mode: "paper", - kind: "dry_run", - }); - expect(mapTradingBotStatus({ mode: "Paper Sim" })).toMatchObject({ - mode: "paper", - kind: "paper", - }); - expect(mapTradingBotStatus({ dry_run: true })).toMatchObject({ kind: "dry_run" }); - expect(mapTradingBotStatus({ live: true })).toMatchObject({ kind: "live" }); - expect(mapTradingBotStatus({ status: "ok" })).toBeNull(); - expect(mapTradingBotStatus({})).toBeNull(); - expect(mapTradingBotStatus(null)).toBeNull(); - expect(mapTradingBotStatus({ live: false })).toBeNull(); - }); - - it("refuses a body that claims live and dry-run together", () => { - expect(mapTradingBotStatus({ mode: "live", dry_run: true })).toBeNull(); - expect(mapTradingBotStatus({ live: true, paper: true })).toBeNull(); - }); - - it("reads a nested mode and safe subtitle fields", () => { - expect( - mapTradingBotStatus({ - data: { mode: "DRY RUN", adapters: ["a", "b", "c"], last_error: "adapter timeout" }, - }), - ).toEqual({ - mode: "paper", - kind: "dry_run", - adapterCount: 3, - lastError: "adapter timeout", - }); - expect(mapTradingBotStatus({ mode: "paper", adapter_count: "4" })?.adapterCount).toBe(4); - }); - - it("drops secret-like errors and still reports the mode", () => { - expect( - mapTradingBotStatus({ mode: "paper", last_error: "Bearer sk-test-secret" })?.lastError, - ).toBeNull(); - expect( - mapTradingBotStatus({ mode: "live", error: "failed http://10.0.0.8/status" })?.lastError, - ).toBeNull(); - expect(sanitizeTradingBotError("ok")).toBeNull(); - const long = "x".repeat(120); - expect(sanitizeTradingBotError(long)?.endsWith("…")).toBe(true); - expect(sanitizeTradingBotError(long)?.length).toBe(80); - }); -}); - -describe("interpretTradingBotProxyResponse", () => { - it("keeps unconfigured, offline, unknown, and a live read distinct", () => { - expect(interpretTradingBotProxyResponse(200, { configured: false })).toEqual({ - state: "unconfigured", - }); - expect(interpretTradingBotProxyResponse(502, { configured: true, reachable: false })).toEqual({ - state: "offline", - }); - expect( - interpretTradingBotProxyResponse(200, { configured: true, reachable: true, status: null }), - ).toEqual({ state: "unknown" }); - expect(interpretTradingBotProxyResponse(401, { error: "Unauthorized" })).toEqual({ - state: "offline", - }); - expect( - interpretTradingBotProxyResponse(200, { - configured: true, - reachable: true, - status: { mode: "live", kind: "live", adapterCount: 1, lastError: null }, - }), - ).toEqual({ - state: "ready", - status: { mode: "live", kind: "live", adapterCount: 1, lastError: null }, - }); - }); - - it("strips an unsafe error from an otherwise valid proxy status", () => { - const snapshot = interpretTradingBotProxyResponse(200, { - configured: true, - reachable: true, - status: { - mode: "paper", - kind: "dry_run", - adapterCount: null, - lastError: "token=super-secret", - }, - }); - expect(snapshot).toEqual({ - state: "ready", - status: { mode: "paper", kind: "dry_run", adapterCount: null, lastError: null }, - }); - expect(coerceTradingBotStatusView({ mode: "live", kind: "paper" })).toBeNull(); - }); -}); - -describe("tradingBotBackoffMs", () => { - it("doubles the interval and then caps it", () => { - expect(tradingBotBackoffMs(20_000, 1, 120_000)).toBe(20_000); - expect(tradingBotBackoffMs(20_000, 2, 120_000)).toBe(40_000); - expect(tradingBotBackoffMs(20_000, 3, 120_000)).toBe(80_000); - expect(tradingBotBackoffMs(20_000, 4, 120_000)).toBe(120_000); - }); -}); diff --git a/packages/core/src/trading-bot-status.ts b/packages/core/src/trading-bot-status.ts deleted file mode 100644 index a282ec8..0000000 --- a/packages/core/src/trading-bot-status.ts +++ /dev/null @@ -1,297 +0,0 @@ -/** Shared Tradingbot status contract. The browser and the API proxy both use this. */ - -export const TRADING_BOT_STATUS_PATH = "/bot/api/status"; -export const TRADING_BOT_PROXY_PATH = "/api/trading-bot/status"; -export const TRADING_BOT_POLL_INTERVAL_MS = 20_000; -export const TRADING_BOT_POLL_MAX_BACKOFF_MS = 120_000; -export const TRADING_BOT_STATUS_TIMEOUT_MS = 5_000; -export const TRADING_BOT_STATUS_MAX_BYTES = 65_536; - -const MAX_ORIGIN_LENGTH = 200; -const MAX_ERROR_LENGTH = 80; -const MAX_ADAPTER_COUNT = 10_000; - -const LIVE_TOKENS = new Set(["live", "live_trading", "real", "production"]); -const PAPER_TOKENS = new Set(["paper", "paper_sim", "paper_trading", "simulation", "sim"]); -const DRY_RUN_TOKENS = new Set(["dry_run", "dryrun", "dry"]); - -const MODE_STRING_KEYS = [ - "mode", - "trading_mode", - "tradingMode", - "run_mode", - "runMode", - "execution_mode", - "executionMode", - "badge", - "status", -] as const; - -const NESTED_KEYS = ["data", "bot", "state", "result", "status"] as const; - -const ADAPTER_COUNT_KEYS = [ - "adapterCount", - "adapter_count", - "adaptersCount", - "adapters_count", - "n_adapters", -] as const; - -const ERROR_KEYS = [ - "lastError", - "last_error", - "last_error_message", - "errorMessage", - "error", -] as const; - -const EMPTY_ERRORS = new Set(["ok", "none", "null", "undefined", "n/a", "-"]); - -const UNSAFE_ERROR = /bearer\s|api[_-]?key|password|secret|token|authorization|cookie|https?:\/\//i; - -export type TradingBotKind = "live" | "paper" | "dry_run"; - -export type TradingBotStatusView = { - mode: "live" | "paper"; - kind: TradingBotKind; - adapterCount: number | null; - lastError: string | null; -}; - -export type TradingBotOrigin = - | { kind: "unset" } - | { kind: "invalid" } - | { kind: "origin"; origin: string }; - -export type TradingBotSnapshot = - | { state: "unconfigured" } - | { state: "offline" } - | { state: "unknown" } - | { state: "ready"; status: TradingBotStatusView }; - -/** Accept a bare http(s) origin. Credentials and other schemes are rejected. */ -export function parseTradingBotOrigin(value: string | undefined): TradingBotOrigin { - const trimmed = value?.trim() ?? ""; - if (!trimmed) return { kind: "unset" }; - if (trimmed.length > MAX_ORIGIN_LENGTH || /\s/.test(trimmed) || hasControlChar(trimmed)) { - return { kind: "invalid" }; - } - let url: URL; - try { - url = new URL(trimmed); - } catch { - return { kind: "invalid" }; - } - if (url.protocol !== "http:" && url.protocol !== "https:") return { kind: "invalid" }; - if (url.username || url.password || !url.hostname) return { kind: "invalid" }; - return { kind: "origin", origin: url.origin }; -} - -/** Throw when the operator set a value that must not be fetched. Unset stays unset. */ -export function resolveTradingBotOrigin(value: string | undefined): string | undefined { - const parsed = parseTradingBotOrigin(value); - if (parsed.kind === "invalid") throw new Error("Invalid TRADING_BOT_URL"); - return parsed.kind === "origin" ? parsed.origin : undefined; -} - -export function tradingBotStatusUrl(origin: string): string { - return `${origin}${TRADING_BOT_STATUS_PATH}`; -} - -export function tradingBotBackoffMs( - intervalMs: number, - failureCount: number, - maxBackoffMs: number, -): number { - const exponent = Math.min(Math.max(failureCount - 1, 0), 6); - return Math.min(intervalMs * 2 ** exponent, maxBackoffMs); -} - -/** Map a Tradingbot `/bot/api/status` body. Unknown or conflicting modes return null. */ -export function mapTradingBotStatus(body: unknown): TradingBotStatusView | null { - const record = asRecord(body); - if (!record) return null; - const kind = resolveKind(readSignals(record)); - if (!kind) return null; - return { - mode: kind === "live" ? "live" : "paper", - kind, - adapterCount: readAdapterCount(record), - lastError: readLastError(record), - }; -} - -/** Accept a proxy payload field. Drops unsafe error text instead of dropping the mode. */ -export function coerceTradingBotStatusView(value: unknown): TradingBotStatusView | null { - const record = asRecord(value); - if (!record) return null; - const { mode, kind } = record; - if (mode !== "live" && mode !== "paper") return null; - if (kind !== "live" && kind !== "paper" && kind !== "dry_run") return null; - if ((mode === "live") !== (kind === "live")) return null; - if (record.adapterCount !== null && parseCount(record.adapterCount) === null) return null; - if (record.lastError !== null && typeof record.lastError !== "string") return null; - return { - mode, - kind, - adapterCount: record.adapterCount === null ? null : parseCount(record.adapterCount), - lastError: record.lastError === null ? null : sanitizeTradingBotError(record.lastError), - }; -} - -export function interpretTradingBotProxyResponse( - status: number, - body: unknown, -): TradingBotSnapshot { - const record = asRecord(body); - if (status === 200 && record?.configured === false) return { state: "unconfigured" }; - if (record?.configured === true && record.reachable === false) return { state: "offline" }; - if (status === 200 && record?.configured === true && record.reachable === true) { - if (record.status == null) return { state: "unknown" }; - const view = coerceTradingBotStatusView(record.status); - return view ? { state: "ready", status: view } : { state: "unknown" }; - } - return { state: "offline" }; -} - -/** Keep a short operator-facing error. Drop secrets, URLs, and empty placeholders. */ -export function sanitizeTradingBotError(value: unknown): string | null { - if (typeof value !== "string") return null; - const cleaned = stripControlChars(value).replace(/\s+/g, " ").trim(); - if (!cleaned || EMPTY_ERRORS.has(cleaned.toLowerCase())) return null; - if (UNSAFE_ERROR.test(cleaned)) return null; - if (cleaned.length <= MAX_ERROR_LENGTH) return cleaned; - return `${cleaned.slice(0, MAX_ERROR_LENGTH - 1)}…`; -} - -function readSignals(record: Record): TradingBotKind[] { - const signals: TradingBotKind[] = []; - pushSignals(record, signals); - if (signals.length > 0) return signals; - for (const key of NESTED_KEYS) { - const nested = asRecord(record[key]); - if (nested) pushSignals(nested, signals); - } - return signals; -} - -function pushSignals(record: Record, signals: TradingBotKind[]): void { - for (const key of MODE_STRING_KEYS) { - const value = record[key]; - if (typeof value !== "string") continue; - const kind = classifyModeToken(value); - if (kind) signals.push(kind); - } - if (record.live === true || record.is_live === true || record.isLive === true) { - signals.push("live"); - } - if (record.dry_run === true || record.dryRun === true) signals.push("dry_run"); - if ( - record.paper === true || - record.is_paper === true || - record.isPaper === true || - record.paper_trading === true || - record.paperTrading === true - ) { - signals.push("paper"); - } -} - -function resolveKind(signals: TradingBotKind[]): TradingBotKind | null { - const unique = new Set(signals); - const live = unique.has("live"); - const paper = unique.has("paper"); - const dryRun = unique.has("dry_run"); - if (live && (paper || dryRun)) return null; - if (dryRun) return "dry_run"; - if (paper) return "paper"; - if (live) return "live"; - return null; -} - -function classifyModeToken(value: string): TradingBotKind | null { - const token = value - .trim() - .toLowerCase() - .replace(/[\s-]+/g, "_"); - if (LIVE_TOKENS.has(token)) return "live"; - if (DRY_RUN_TOKENS.has(token)) return "dry_run"; - if (PAPER_TOKENS.has(token)) return "paper"; - return null; -} - -function readAdapterCount(record: Record): number | null { - const found = readOptional(record, adapterCountIn); - return found === undefined ? null : found; -} - -function adapterCountIn(record: Record): number | null | undefined { - for (const key of ADAPTER_COUNT_KEYS) { - if (!Object.hasOwn(record, key)) continue; - return parseCount(record[key]); - } - if (Object.hasOwn(record, "adapters") && Array.isArray(record.adapters)) { - return parseCount(record.adapters.length); - } - return undefined; -} - -function readLastError(record: Record): string | null { - const found = readOptional(record, lastErrorIn); - return found === undefined ? null : found; -} - -function lastErrorIn(record: Record): string | null | undefined { - for (const key of ERROR_KEYS) { - if (!Object.hasOwn(record, key)) continue; - return sanitizeTradingBotError(record[key]); - } - return undefined; -} - -function readOptional( - record: Record, - read: (row: Record) => T | undefined, -): T | undefined { - const direct = read(record); - if (direct !== undefined) return direct; - for (const key of NESTED_KEYS) { - const nested = asRecord(record[key]); - if (!nested) continue; - const value = read(nested); - if (value !== undefined) return value; - } - return undefined; -} - -function parseCount(value: unknown): number | null { - if (typeof value === "string" && !/^\d+$/.test(value.trim())) return null; - const numeric = - typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN; - if (!Number.isInteger(numeric) || numeric < 0 || numeric > MAX_ADAPTER_COUNT) return null; - return numeric; -} - -function hasControlChar(value: string): boolean { - for (let index = 0; index < value.length; index += 1) { - const code = value.charCodeAt(index); - if (code < 32 || code === 127) return true; - } - return false; -} - -function stripControlChars(value: string): string { - let out = ""; - for (let index = 0; index < value.length; index += 1) { - const code = value.charCodeAt(index); - const char = value[index]; - if (char === undefined) continue; - out += code < 32 || code === 127 ? " " : char; - } - return out; -} - -function asRecord(value: unknown): Record | null { - if (!value || typeof value !== "object" || Array.isArray(value)) return null; - return value as Record; -}