diff --git a/.env.example b/.env.example index 1de0602..08aac75 100644 --- a/.env.example +++ b/.env.example @@ -217,6 +217,15 @@ EVOO_COCKPIT_TOKEN= # Required off-loopback when a browser shell (not the iframe) must call Cockpit APIs. EVOO_COCKPIT_CORS_ORIGIN= +# 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 76f1137..46f448a 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -36,6 +36,7 @@ import { EncryptedSecretStore, ExpoPushProvider, GraphileJobPublisher, + GraphileJobWorkerHost, InMemoryJobQueue, InMemoryRealtimeFanout, InstalledConnectorProvider, @@ -55,7 +56,6 @@ import { pipedreamConfigFromEnv, piSessionsRoot, pushTokenPath, - GraphileJobWorkerHost, reconcileCloudAgents, reconcileComputerUpdates, removePiUserSessions, @@ -107,6 +107,7 @@ 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"; @@ -508,6 +509,13 @@ 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 cc8ca9d..029eb12 100644 --- a/apps/api/src/env.test.ts +++ b/apps/api/src/env.test.ts @@ -35,6 +35,16 @@ describe("loadEnv", () => { expect(env.wakeupDriver).toBe("memory"); }); + 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 0831871..45e2936 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -8,6 +8,7 @@ import { resolveEncryptionKey, resolveScreenProxySecret, resolveSupervisorToken, + resolveTradingBotOrigin, } from "@rakazo/core"; export { resolveCloudAgentProvider, resolveSandboxProvider } from "@rakazo/adapters"; @@ -91,6 +92,8 @@ 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 { @@ -177,6 +180,7 @@ 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 new file mode 100644 index 0000000..22e0808 --- /dev/null +++ b/apps/api/src/trading-bot-proxy.test.ts @@ -0,0 +1,96 @@ +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 new file mode 100644 index 0000000..16bd736 --- /dev/null +++ b/apps/api/src/trading-bot-proxy.ts @@ -0,0 +1,79 @@ +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/e2e/sidebar-nav.spec.ts b/apps/web/e2e/sidebar-nav.spec.ts index 0f59c58..139fe12 100644 --- a/apps/web/e2e/sidebar-nav.spec.ts +++ b/apps/web/e2e/sidebar-nav.spec.ts @@ -23,7 +23,7 @@ test("sidebar has expected navigation items and no integrations", async ({ page // - textbox "Search" (search input) // - button "Heute" (today) // - text "Unterhaltungen" (conversations section header) - // - button "Live Trading" + // - status "Not configured" (trading bot URL unset) // - NO "Integration(en)" button or text // Search: it's a textbox, not a button @@ -38,9 +38,12 @@ test("sidebar has expected navigation items and no integrations", async ({ page const conversationsText = sidebar.getByText("Unterhaltungen"); await expect(conversationsText).toBeVisible({ timeout: 5000 }); - // "Live Trading" - const liveTradingBtn = sidebar.getByRole("button", { name: "Live Trading" }); - await expect(liveTradingBtn).toBeVisible({ timeout: 5000 }); + // Trading mode comes from the bot API. With no URL configured the sidebar + // must not claim Live Trading from a local preference. + const tradingStatus = sidebar.getByTestId("trading-mode-status"); + await expect(tradingStatus).toBeVisible({ timeout: 5000 }); + await expect(tradingStatus).toContainText("Not configured"); + await expect(sidebar.getByRole("button", { name: "Live Trading" })).toHaveCount(0); // No "Integration(en)" button const integrationBtn = sidebar.getByRole("button", { name: /Integration(en)?/i }); diff --git a/apps/web/src/components/TradingModeIndicator.test.tsx b/apps/web/src/components/TradingModeIndicator.test.tsx new file mode 100644 index 0000000..db8bbbc --- /dev/null +++ b/apps/web/src/components/TradingModeIndicator.test.tsx @@ -0,0 +1,55 @@ +// @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 new file mode 100644 index 0000000..6780aa1 --- /dev/null +++ b/apps/web/src/components/TradingModeIndicator.tsx @@ -0,0 +1,95 @@ +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 new file mode 100644 index 0000000..95a86aa --- /dev/null +++ b/apps/web/src/lib/trading-bot-status.test.ts @@ -0,0 +1,178 @@ +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 new file mode 100644 index 0000000..5b04ec0 --- /dev/null +++ b/apps/web/src/lib/trading-bot-status.ts @@ -0,0 +1,243 @@ +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 1199e7b..b01606f 100644 --- a/apps/web/src/pages/AccountSettingsOverlay.tsx +++ b/apps/web/src/pages/AccountSettingsOverlay.tsx @@ -1,7 +1,7 @@ import { Trans, useLingui } from "@lingui/react/macro"; import type { AvatarStyle } from "@rakazo/contracts"; import { BotAvatar, Button, Field, FieldLabel, Input, Toggle } from "@rakazo/ui-web"; -import { Activity, ChevronDown } from "lucide-react"; +import { ChevronDown } from "lucide-react"; import { type KeyboardEvent as ReactKeyboardEvent, type RefObject, @@ -16,8 +16,10 @@ 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, @@ -33,59 +35,21 @@ export type SettingsGeneralProps = { messagingEnabled?: boolean; onOpenMessaging?: () => void; isDeploymentOwner?: boolean; - tradingMode?: "live" | "paper"; - onDiscoverLiveTrading?: () => void; + tradingStatus?: TradingBotUiState; }; -export function LiveTradingBanner({ - tradingMode, - onDiscover, -}: { - tradingMode: "live" | "paper"; - onDiscover: () => void; -}) { - const isLive = tradingMode === "live"; +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 (
-
-
- - - -
-

- Live Trading -

- {isLive ? ( -

- Active -

- ) : null} -
-
- {!isLive ? ( - - ) : null} -
+
); } @@ -98,8 +62,7 @@ export function GeneralSettingsPanels({ messagingEnabled = false, onOpenMessaging, isDeploymentOwner = false, - tradingMode, - onDiscoverLiveTrading, + tradingStatus, }: SettingsGeneralProps) { const { t } = useLingui(); const [locale, setLocale] = useState(() => getActiveUiLocale()); @@ -135,9 +98,7 @@ export function GeneralSettingsPanels({ return (
- {tradingMode && onDiscoverLiveTrading ? ( - - ) : null} + {tradingStatus ? : null}

Account diff --git a/apps/web/src/pages/SettingsOverlay.tsx b/apps/web/src/pages/SettingsOverlay.tsx index 8562d4d..63b1deb 100644 --- a/apps/web/src/pages/SettingsOverlay.tsx +++ b/apps/web/src/pages/SettingsOverlay.tsx @@ -14,6 +14,7 @@ import { } from "lucide-react"; import { type ComponentType, useEffect, useRef, useState } from "react"; import { computersAreUnavailable } from "../components/ComputersUnavailableHint"; +import type { TradingBotUiState } from "../lib/trading-bot-status"; import { ComputerSettingsPanel, GeneralSettingsPanels, @@ -58,8 +59,7 @@ export function SettingsOverlay({ onVoiceStatusMaybeChanged, activeBotId, onOpenMcp, - tradingMode, - onDiscoverLiveTrading, + tradingStatus, }: { email?: string | null; name: string; @@ -77,8 +77,7 @@ export function SettingsOverlay({ onVoiceStatusMaybeChanged?: () => void | Promise; activeBotId?: string; onOpenMcp?: () => void; - tradingMode?: "live" | "paper"; - onDiscoverLiveTrading?: () => void; + tradingStatus?: TradingBotUiState; }) { const { t } = useLingui(); const panelRef = useRef(null); @@ -231,8 +230,7 @@ export function SettingsOverlay({ onOpenMessaging ? () => leaveSettings(onOpenMessaging) : undefined } isDeploymentOwner={isDeploymentOwner} - tradingMode={tradingMode} - onDiscoverLiveTrading={onDiscoverLiveTrading} + tradingStatus={tradingStatus} /> ) : null} {section === "integrations" ? ( diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index 4f70d6a..4575dfb 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -45,7 +45,9 @@ import { isRunTerminalEvent, isToolActivityBlock, latestAnswerableAskMessageId, + matchVoiceByName, mentionChipKey, + parseVoiceChatCommand, projectMessageReactions, reorderBotTo, resolveComposerSendPlan, @@ -57,8 +59,6 @@ import { serializeComposerPrompt, speechFromBlocks, truncateSlashDescription, - matchVoiceByName, - parseVoiceChatCommand, userVisibleMessages, } from "@rakazo/core"; import { @@ -83,7 +83,6 @@ import { Wordmark, } from "@rakazo/ui-web"; import { - Activity, ArrowDown, ArrowLeft, ArrowUp, @@ -152,6 +151,7 @@ import { import { ComputerUpdateProgress } from "../components/ComputerUpdateProgress"; import { MessageHoverMetadata } from "../components/MessageHoverMetadata"; import { OoGroupStage, OoStage } from "../components/stage/OoStage"; +import { TradingModeIndicator } from "../components/TradingModeIndicator"; import { SkillDraftCard } from "../components/teach/SkillDraftCard"; import { TeachCaptureOverlay } from "../components/teach/TeachCaptureOverlay"; import { TeachComputerOverlayControl } from "../components/teach/TeachComputerOverlay"; @@ -172,9 +172,9 @@ import { requestBrowserNotificationPermission, shouldNotifyBrowser, } from "../lib/browser-notifications"; +import { browserPreviewActivity } from "../lib/browser-preview-context"; import { loadComputerScreen } from "../lib/computer-screen"; import { desktopBridge } from "../lib/desktop"; -import { browserPreviewActivity } from "../lib/browser-preview-context"; import { scheduleFocusPrompt } from "../lib/focus-prompt"; import { isWorkingRunStatus, @@ -216,6 +216,7 @@ import { threadRunError, userHoldsComputerControl, } from "../lib/thread-events"; +import { useTradingBotStatus } from "../lib/trading-bot-status"; import { transcriptCanSnapAfterFrame, transcriptIsNearEnd, @@ -366,23 +367,7 @@ export function ShellPage() { if (botId) return "team"; return "heute"; }); - const [tradingMode, setTradingMode] = useState<"live" | "paper">(() => { - if (typeof window !== "undefined" && window.localStorage) { - const saved = window.localStorage.getItem("evoo_trading_mode"); - if (saved === "paper" || saved === "live") return saved; - } - return "live"; - }); - - const toggleTradingMode = useCallback(() => { - setTradingMode((prev) => { - const next = prev === "live" ? "paper" : "live"; - if (typeof window !== "undefined" && window.localStorage) { - window.localStorage.setItem("evoo_trading_mode", next); - } - return next; - }); - }, []); + const tradingStatus = useTradingBotStatus(); useEffect(() => { if (botId) setActiveNavPillar("team"); @@ -567,15 +552,6 @@ export function ShellPage() { const [mcpOpen, setMcpOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); - const discoverLiveTrading = useCallback(() => { - setTradingMode("live"); - if (typeof window !== "undefined" && window.localStorage) { - window.localStorage.setItem("evoo_trading_mode", "live"); - } - setActiveNavPillar("heute"); - navigate("/app"); - }, [navigate]); - const [settingsSection, setSettingsSection] = useState("general"); const [messagingSettingsOpen, setMessagingSettingsOpen] = useState(false); const [messagingSurfaceEnabled, setMessagingSurfaceEnabled] = useState(false); @@ -1775,7 +1751,7 @@ export function ShellPage() { abort.abort(); window.clearTimeout(timer); }; - }, [showSpaceSearch, Calendar, Activity, Settings, spaceQuery]); + }, [showSpaceSearch, Calendar, Settings, spaceQuery]); async function jumpToSearchHit(hit: SearchHit) { setQuery(""); @@ -3910,27 +3886,7 @@ export function ShellPage() {

) : null} - + { 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 f69daed..933aa4a 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -2,6 +2,8 @@ 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 83d9f49..90fb9f1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -45,5 +45,6 @@ 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 new file mode 100644 index 0000000..38f0438 --- /dev/null +++ b/packages/core/src/trading-bot-status.test.ts @@ -0,0 +1,141 @@ +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 new file mode 100644 index 0000000..a282ec8 --- /dev/null +++ b/packages/core/src/trading-bot-status.ts @@ -0,0 +1,297 @@ +/** 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; +}