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_URL=
|
||||||
RAKAZO_UPDATER_TOKEN=
|
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).
|
# 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.
|
# Overridden by localStorage key rakazo.uiLocale when the user picks a language in Settings.
|
||||||
# VITE_DEFAULT_UI_LOCALE=en
|
# VITE_DEFAULT_UI_LOCALE=en
|
||||||
|
|||||||
@ -109,7 +109,6 @@ import {
|
|||||||
settleWithTimeout,
|
settleWithTimeout,
|
||||||
TEAM_CHAT_STARTUP_SHUTDOWN_MS,
|
TEAM_CHAT_STARTUP_SHUTDOWN_MS,
|
||||||
} from "./team-chat-startup.js";
|
} from "./team-chat-startup.js";
|
||||||
import { mountTradingBotStatus } from "./trading-bot-proxy.js";
|
|
||||||
import { mountVoiceHttpRoutes } from "./voice.js";
|
import { mountVoiceHttpRoutes } from "./voice.js";
|
||||||
import { mountWebhookHttpRoutes } from "./webhook.js";
|
import { mountWebhookHttpRoutes } from "./webhook.js";
|
||||||
|
|
||||||
@ -530,13 +529,6 @@ export async function createApp(
|
|||||||
resetUrl: email ? new URL("/reset-password", env.webOrigin).href : null,
|
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") {
|
if (localEmailEmulator && env.nodeEnv === "development") {
|
||||||
app.get(
|
app.get(
|
||||||
"/api/dev/emails",
|
"/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", () => {
|
it("loads an optional integrations catalog mirror", () => {
|
||||||
expect(loadEnv(base).integrationsCatalogUrl).toBeUndefined();
|
expect(loadEnv(base).integrationsCatalogUrl).toBeUndefined();
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import {
|
|||||||
resolveEncryptionKey,
|
resolveEncryptionKey,
|
||||||
resolveScreenProxySecret,
|
resolveScreenProxySecret,
|
||||||
resolveSupervisorToken,
|
resolveSupervisorToken,
|
||||||
resolveTradingBotOrigin,
|
|
||||||
} from "@rakazo/core";
|
} from "@rakazo/core";
|
||||||
|
|
||||||
export { resolveCloudAgentProvider, resolveSandboxProvider } from "@rakazo/adapters";
|
export { resolveCloudAgentProvider, resolveSandboxProvider } from "@rakazo/adapters";
|
||||||
@ -93,8 +92,6 @@ export interface AppEnv {
|
|||||||
updaterToken: string | undefined;
|
updaterToken: string | undefined;
|
||||||
/** Current application image tag; used for compose manual-upgrade command selection. */
|
/** Current application image tag; used for compose manual-upgrade command selection. */
|
||||||
imageTag: string | undefined;
|
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 {
|
export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
|
||||||
@ -182,7 +179,6 @@ export function loadEnv(source: NodeJS.ProcessEnv = process.env): AppEnv {
|
|||||||
updaterUrl,
|
updaterUrl,
|
||||||
updaterToken,
|
updaterToken,
|
||||||
imageTag: optional(source.RAKAZO_IMAGE_TAG),
|
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 { ComputersUnavailableHint } from "../components/ComputersUnavailableHint";
|
||||||
import { DesktopUpdateSection } from "../components/DesktopUpdates";
|
import { DesktopUpdateSection } from "../components/DesktopUpdates";
|
||||||
import { SoftwareUpdateSection } from "../components/SoftwareUpdateSection";
|
import { SoftwareUpdateSection } from "../components/SoftwareUpdateSection";
|
||||||
import { TradingModeIndicator } from "../components/TradingModeIndicator";
|
|
||||||
import { authClient } from "../lib/auth";
|
import { authClient } from "../lib/auth";
|
||||||
import { getActiveUiLocale, setUiLocale } from "../lib/i18n";
|
import { getActiveUiLocale, setUiLocale } from "../lib/i18n";
|
||||||
import type { TradingBotUiState } from "../lib/trading-bot-status";
|
|
||||||
import {
|
import {
|
||||||
type AppearancePreference,
|
type AppearancePreference,
|
||||||
getUiAppearancePreference,
|
getUiAppearancePreference,
|
||||||
@ -35,25 +33,8 @@ export type SettingsGeneralProps = {
|
|||||||
messagingEnabled?: boolean;
|
messagingEnabled?: boolean;
|
||||||
onOpenMessaging?: () => void;
|
onOpenMessaging?: () => void;
|
||||||
isDeploymentOwner?: boolean;
|
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({
|
export function GeneralSettingsPanels({
|
||||||
email,
|
email,
|
||||||
name,
|
name,
|
||||||
@ -62,7 +43,6 @@ export function GeneralSettingsPanels({
|
|||||||
messagingEnabled = false,
|
messagingEnabled = false,
|
||||||
onOpenMessaging,
|
onOpenMessaging,
|
||||||
isDeploymentOwner = false,
|
isDeploymentOwner = false,
|
||||||
tradingStatus,
|
|
||||||
}: SettingsGeneralProps) {
|
}: SettingsGeneralProps) {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const [locale, setLocale] = useState<UiLocale>(() => getActiveUiLocale());
|
const [locale, setLocale] = useState<UiLocale>(() => getActiveUiLocale());
|
||||||
@ -98,7 +78,6 @@ export function GeneralSettingsPanels({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{tradingStatus ? <LiveTradingBanner snapshot={tradingStatus} /> : null}
|
|
||||||
<section className="rounded-xl border border-border px-4 py-4">
|
<section className="rounded-xl border border-border px-4 py-4">
|
||||||
<h3 className="text-ui-body font-medium text-foreground">
|
<h3 className="text-ui-body font-medium text-foreground">
|
||||||
<Trans>Account</Trans>
|
<Trans>Account</Trans>
|
||||||
|
|||||||
@ -17,7 +17,6 @@ import {
|
|||||||
import { type ComponentType, useEffect, useRef, useState } from "react";
|
import { type ComponentType, useEffect, useRef, useState } from "react";
|
||||||
import { computersAreUnavailable } from "../components/ComputersUnavailableHint";
|
import { computersAreUnavailable } from "../components/ComputersUnavailableHint";
|
||||||
import { UserProfileSettings } from "../components/UserProfileSettings";
|
import { UserProfileSettings } from "../components/UserProfileSettings";
|
||||||
import type { TradingBotUiState } from "../lib/trading-bot-status";
|
|
||||||
import {
|
import {
|
||||||
ComputerSettingsPanel,
|
ComputerSettingsPanel,
|
||||||
GeneralSettingsPanels,
|
GeneralSettingsPanels,
|
||||||
@ -65,7 +64,6 @@ export function SettingsOverlay({
|
|||||||
onVoiceStatusMaybeChanged,
|
onVoiceStatusMaybeChanged,
|
||||||
activeBotId,
|
activeBotId,
|
||||||
onOpenMcp,
|
onOpenMcp,
|
||||||
tradingStatus,
|
|
||||||
}: {
|
}: {
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
@ -83,7 +81,6 @@ export function SettingsOverlay({
|
|||||||
onVoiceStatusMaybeChanged?: () => void | Promise<void>;
|
onVoiceStatusMaybeChanged?: () => void | Promise<void>;
|
||||||
activeBotId?: string;
|
activeBotId?: string;
|
||||||
onOpenMcp?: () => void;
|
onOpenMcp?: () => void;
|
||||||
tradingStatus?: TradingBotUiState;
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
@ -238,7 +235,6 @@ export function SettingsOverlay({
|
|||||||
onOpenMessaging ? () => leaveSettings(onOpenMessaging) : undefined
|
onOpenMessaging ? () => leaveSettings(onOpenMessaging) : undefined
|
||||||
}
|
}
|
||||||
isDeploymentOwner={isDeploymentOwner}
|
isDeploymentOwner={isDeploymentOwner}
|
||||||
tradingStatus={tradingStatus}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{section === "integrations" ? (
|
{section === "integrations" ? (
|
||||||
|
|||||||
@ -249,7 +249,6 @@ import {
|
|||||||
threadRunError,
|
threadRunError,
|
||||||
userHoldsComputerControl,
|
userHoldsComputerControl,
|
||||||
} from "../lib/thread-events";
|
} from "../lib/thread-events";
|
||||||
import { useTradingBotStatus } from "../lib/trading-bot-status";
|
|
||||||
import {
|
import {
|
||||||
transcriptCanSnapAfterFrame,
|
transcriptCanSnapAfterFrame,
|
||||||
transcriptIsNearEnd,
|
transcriptIsNearEnd,
|
||||||
@ -422,7 +421,6 @@ export function ShellPage() {
|
|||||||
if (botId) return "team";
|
if (botId) return "team";
|
||||||
return "heute";
|
return "heute";
|
||||||
});
|
});
|
||||||
const tradingStatus = useTradingBotStatus();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (botId) setActiveNavPillar("team");
|
if (botId) setActiveNavPillar("team");
|
||||||
@ -5553,7 +5551,6 @@ export function ShellPage() {
|
|||||||
setSettingsOpen(false);
|
setSettingsOpen(false);
|
||||||
setMessagingSettingsOpen(true);
|
setMessagingSettingsOpen(true);
|
||||||
}}
|
}}
|
||||||
tradingStatus={tradingStatus}
|
|
||||||
onAvatarStyleChange={async (avatarStyle) => {
|
onAvatarStyleChange={async (avatarStyle) => {
|
||||||
const nextMe = await rpc.preferences.update({ avatarStyle });
|
const nextMe = await rpc.preferences.update({ avatarStyle });
|
||||||
setBootstrapMe(nextMe);
|
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 {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_DEFAULT_UI_LOCALE?: string;
|
readonly VITE_DEFAULT_UI_LOCALE?: string;
|
||||||
/** Optional Tradingbot origin. Empty uses the same-origin API proxy. */
|
|
||||||
readonly VITE_TRADING_BOT_URL?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
@ -45,7 +45,6 @@ export * from "./text-direction.js";
|
|||||||
export * from "./thread-message-updates.js";
|
export * from "./thread-message-updates.js";
|
||||||
export * from "./thread-subscription.js";
|
export * from "./thread-subscription.js";
|
||||||
export * from "./tool-activity.js";
|
export * from "./tool-activity.js";
|
||||||
export * from "./trading-bot-status.js";
|
|
||||||
export * from "./voice-chat-command.js";
|
export * from "./voice-chat-command.js";
|
||||||
export * from "./voice-labels.js";
|
export * from "./voice-labels.js";
|
||||||
export * from "./workspace-focus.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