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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
Claude 2026-10-03 13:08:58 +00:00
parent c22a25d7ab
commit 105a3094b1
No known key found for this signature in database
17 changed files with 0 additions and 1246 deletions

View File

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

View File

@ -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",

View File

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

View File

@ -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),
};
}

View File

@ -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<boolean>;
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 });
});
});

View File

@ -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<boolean>;
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<unknown>(
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.
}
}

View File

@ -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(
<TradingModeIndicator
snapshot={{
state: "ready",
status: { mode: "paper", kind: "dry_run", adapterCount: 3, lastError: null },
}}
/>,
),
);
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(<TradingModeIndicator snapshot={{ state: "offline" }} />));
expect(container.querySelector("[data-testid='trading-mode-status']")?.textContent).toContain(
"Offline",
);
await act(async () =>
root.render(<TradingModeIndicator snapshot={{ state: "unconfigured" }} />),
);
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();
}
});

View File

@ -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 (
<div
role="status"
aria-live="polite"
aria-busy={presentation.label === "loading"}
data-testid="trading-mode-status"
data-trading-state={presentation.label}
className={cn("flex min-w-0 items-center gap-3 rounded-xl px-2.5 py-2", className)}
>
<span
className={cn(
"grid h-[30px] w-[30px] shrink-0 place-items-center rounded-lg",
presentation.tone === "live"
? "bg-warning/10 text-warning"
: "bg-muted text-muted-foreground",
)}
>
<Activity
strokeWidth={1.8}
aria-hidden="true"
className={cn("size-icon-md", presentation.pulse && "animate-pulse")}
/>
</span>
<span className="min-w-0">
<span className="block truncate text-ui-body font-medium text-foreground/90">
<TradingModeLabel label={presentation.label} />
</span>
{presentation.subtitle ? (
<span className="block truncate text-ui-body-sm text-muted-foreground/70">
<TradingModeSubtitleText subtitle={presentation.subtitle} />
</span>
) : null}
</span>
</div>
);
}
function TradingModeLabel({ label }: { label: TradingModeLabelId }): ReactNode {
switch (label) {
case "loading":
return <Trans>Trading</Trans>;
case "unconfigured":
return <Trans>Not configured</Trans>;
case "offline":
return <Trans>Offline</Trans>;
case "unknown":
return <Trans>Unknown</Trans>;
case "live":
return <Trans>Live Trading</Trans>;
case "paper":
return <Trans>Paper</Trans>;
case "dry_run":
return <Trans>Dry run</Trans>;
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 ? (
<Trans>1 adapter</Trans>
) : (
<Trans>{subtitle.count} adapters</Trans>
);
default: {
const exhaustive: never = subtitle;
return exhaustive;
}
}
}

View File

@ -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<boolean>((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" },
});
}

View File

@ -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<TradingBotSnapshot>;
/** 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<TradingBotSnapshot> {
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<unknown>(
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<boolean>;
}): Promise<void> {
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<TradingBotUiState>({ 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<boolean> {
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.
}
}

View File

@ -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 (
<section
className={`rounded-xl border px-2 py-2 ${
live ? "border-warning/30 bg-warning/5" : "border-border bg-card/40"
}`}
data-testid="live-trading-banner"
>
<TradingModeIndicator snapshot={snapshot} />
</section>
);
}
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<UiLocale>(() => getActiveUiLocale());
@ -98,7 +78,6 @@ export function GeneralSettingsPanels({
return (
<div className="space-y-5">
{tradingStatus ? <LiveTradingBanner snapshot={tradingStatus} /> : null}
<section className="rounded-xl border border-border px-4 py-4">
<h3 className="text-ui-body font-medium text-foreground">
<Trans>Account</Trans>

View File

@ -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<void>;
activeBotId?: string;
onOpenMcp?: () => void;
tradingStatus?: TradingBotUiState;
}) {
const { t } = useLingui();
const panelRef = useRef<HTMLDivElement>(null);
@ -238,7 +235,6 @@ export function SettingsOverlay({
onOpenMessaging ? () => leaveSettings(onOpenMessaging) : undefined
}
isDeploymentOwner={isDeploymentOwner}
tradingStatus={tradingStatus}
/>
) : null}
{section === "integrations" ? (

View File

@ -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);

View File

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

View File

@ -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";

View File

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

View File

@ -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<string, unknown>): 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<string, unknown>, 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<string, unknown>): number | null {
const found = readOptional(record, adapterCountIn);
return found === undefined ? null : found;
}
function adapterCountIn(record: Record<string, unknown>): 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, unknown>): string | null {
const found = readOptional(record, lastErrorIn);
return found === undefined ? null : found;
}
function lastErrorIn(record: Record<string, unknown>): string | null | undefined {
for (const key of ERROR_KEYS) {
if (!Object.hasOwn(record, key)) continue;
return sanitizeTradingBotError(record[key]);
}
return undefined;
}
function readOptional<T>(
record: Record<string, unknown>,
read: (row: Record<string, unknown>) => 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<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}