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:
parent
65103e32e6
commit
13fc64e5ef
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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 });
|
||||
});
|
||||
});
|
||||
@ -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.
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
@ -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 rk-sidebar-muted">
|
||||
<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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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" },
|
||||
});
|
||||
}
|
||||
@ -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.
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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" ? (
|
||||
|
||||
@ -249,7 +249,6 @@ import {
|
||||
threadRunError,
|
||||
userHoldsComputerControl,
|
||||
} from "../lib/thread-events";
|
||||
import { useTradingBotStatus } from "../lib/trading-bot-status";
|
||||
import {
|
||||
transcriptCanSnapAfterFrame,
|
||||
transcriptIsNearEnd,
|
||||
@ -422,7 +421,6 @@ export function ShellPage() {
|
||||
if (botId) return "team";
|
||||
return "heute";
|
||||
});
|
||||
const tradingStatus = useTradingBotStatus();
|
||||
|
||||
useEffect(() => {
|
||||
if (botId) setActiveNavPillar("team");
|
||||
@ -5553,7 +5551,6 @@ export function ShellPage() {
|
||||
setSettingsOpen(false);
|
||||
setMessagingSettingsOpen(true);
|
||||
}}
|
||||
tradingStatus={tradingStatus}
|
||||
onAvatarStyleChange={async (avatarStyle) => {
|
||||
const nextMe = await rpc.preferences.update({ avatarStyle });
|
||||
setBootstrapMe(nextMe);
|
||||
|
||||
2
apps/web/src/vite-env.d.ts
vendored
2
apps/web/src/vite-env.d.ts
vendored
@ -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 {
|
||||
|
||||
@ -45,7 +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";
|
||||
export * from "./workspace-focus.js";
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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>;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user