fix: route through active backend on environment switch (automations) (#182)

This commit is contained in:
Hiep Le
2026-05-08 20:28:39 +07:00
committed by GitHub
parent 1d3c72ff3a
commit 05cb9569d9
9 changed files with 531 additions and 35 deletions
+120 -6
View File
@@ -4,13 +4,17 @@ import type {
AutomationsResponse,
AutomationRunsResponse,
} from "#/types/automation";
import type { Backend } from "#/api/backend-registry/types";
// Use vi.hoisted to define mocks that will be available during vi.mock hoisting
const { mockGet, mockPatch, mockDelete } = vi.hoisted(() => ({
mockGet: vi.fn(),
mockPatch: vi.fn(),
mockDelete: vi.fn(),
}));
const { mockGet, mockPatch, mockDelete, mockCallCloudProxy, mockGetActive } =
vi.hoisted(() => ({
mockGet: vi.fn(),
mockPatch: vi.fn(),
mockDelete: vi.fn(),
mockCallCloudProxy: vi.fn(),
mockGetActive: vi.fn(),
}));
vi.mock("axios", () => ({
default: {
@@ -27,9 +31,33 @@ vi.mock("axios", () => ({
},
}));
vi.mock("#/api/cloud/proxy", () => ({
callCloudProxy: mockCallCloudProxy,
}));
vi.mock("#/api/backend-registry/active-store", () => ({
getActiveBackend: mockGetActive,
}));
// Import after mocking
import AutomationService from "#/api/automation-service/automation-service.api";
const localBackend: Backend = {
id: "local-1",
name: "Local",
host: "http://localhost:8000",
apiKey: "session-key",
kind: "local",
};
const cloudBackend: Backend = {
id: "cloud-1",
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "bearer-key",
kind: "cloud",
};
const mockAutomation: Automation = {
id: "1",
name: "Test Automation",
@@ -44,7 +72,18 @@ const mockAutomation: Automation = {
describe("AutomationService", () => {
beforeEach(() => {
vi.clearAllMocks();
// restoreAllMocks (vs clearAllMocks) re-attaches the original
// implementations of any class methods spied via vi.spyOn in earlier
// tests, so the cloud-routing assertions actually exercise the real
// method bodies instead of stale spies.
vi.restoreAllMocks();
mockGet.mockReset();
mockPatch.mockReset();
mockDelete.mockReset();
mockCallCloudProxy.mockReset();
// Default: active backend is local. Cloud-routing tests override this.
mockGetActive.mockReset();
mockGetActive.mockReturnValue({ backend: localBackend, orgId: null });
});
describe("listAutomations", () => {
@@ -200,4 +239,79 @@ describe("AutomationService", () => {
expect(result).toEqual(toggled);
});
});
// When the active backend is cloud the local axios instance must be
// bypassed entirely; calls must route through `callCloudProxy` so the
// bundled local agent-server forwards the request server-side to the
// cloud host.
describe("cloud routing", () => {
beforeEach(() => {
mockGetActive.mockReturnValue({ backend: cloudBackend, orgId: null });
});
it("listAutomations routes to callCloudProxy with pagination in the path", async () => {
const response: AutomationsResponse = {
automations: [mockAutomation],
total: 1,
};
mockCallCloudProxy.mockResolvedValue(response);
const result = await AutomationService.listAutomations({
limit: 10,
offset: 5,
});
expect(mockCallCloudProxy).toHaveBeenCalledWith({
backend: cloudBackend,
method: "GET",
path: "/api/automation/v1?limit=10&offset=5",
});
expect(mockGet).not.toHaveBeenCalled();
expect(result).toEqual(response);
});
it("getAutomation routes to callCloudProxy with the id in the path", async () => {
mockCallCloudProxy.mockResolvedValue(mockAutomation);
const result = await AutomationService.getAutomation("abc");
expect(mockCallCloudProxy).toHaveBeenCalledWith({
backend: cloudBackend,
method: "GET",
path: "/api/automation/v1/abc",
});
expect(result).toEqual(mockAutomation);
});
it("updateAutomation forwards method PATCH and body via callCloudProxy", async () => {
const updated = { ...mockAutomation, enabled: false };
mockCallCloudProxy.mockResolvedValue(updated);
const result = await AutomationService.updateAutomation("abc", {
enabled: false,
});
expect(mockCallCloudProxy).toHaveBeenCalledWith({
backend: cloudBackend,
method: "PATCH",
path: "/api/automation/v1/abc",
body: { enabled: false },
});
expect(mockPatch).not.toHaveBeenCalled();
expect(result).toEqual(updated);
});
it("deleteAutomation forwards method DELETE via callCloudProxy", async () => {
mockCallCloudProxy.mockResolvedValue(undefined);
await AutomationService.deleteAutomation("abc");
expect(mockCallCloudProxy).toHaveBeenCalledWith({
backend: cloudBackend,
method: "DELETE",
path: "/api/automation/v1/abc",
});
expect(mockDelete).not.toHaveBeenCalled();
});
});
});
@@ -427,6 +427,49 @@ describe("BackendSelector", () => {
);
});
it("redirects to the automations list when switching backends from an automation detail route", async () => {
function AutomationDetailRoute() {
return (
<TestSeed
onMount={(ctx) => {
ctx.addBackend({
name: "Local 1",
host: "http://localhost:9000",
apiKey: "k",
kind: "local",
});
}}
>
<BackendSelector />
</TestSeed>
);
}
function AutomationsListRoute() {
return <div data-testid="automations-list" />;
}
const RouterStub = createRoutesStub([
{ path: "/automations/:automationId", Component: AutomationDetailRoute },
{ path: "/automations", Component: AutomationsListRoute },
]);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
render(
<QueryClientProvider client={queryClient}>
<ActiveBackendProvider>
<RouterStub initialEntries={["/automations/abc-123"]} />
</ActiveBackendProvider>
</QueryClientProvider>,
);
const user = await openDropdown();
await user.click(screen.getByText("Local 1"));
expect(
await screen.findByTestId("automations-list"),
).toBeInTheDocument();
});
it("does not redirect when switching backends from a non-conversation route", async () => {
function SettingsRoute() {
return (
@@ -0,0 +1,123 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import React from "react";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import AutomationService from "#/api/automation-service/automation-service.api";
import {
__resetActiveStoreForTests,
setActiveSelection,
setRegisteredBackends,
} from "#/api/backend-registry/active-store";
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
import { useAutomations } from "#/hooks/query/use-automations";
import { useAutomationDetail } from "#/hooks/query/use-automation-detail";
import type { Backend } from "#/api/backend-registry/types";
import type {
Automation,
AutomationsResponse,
} from "#/types/automation";
vi.mock("#/api/automation-service/automation-service.api", () => ({
default: {
getAutomations: vi.fn(),
getAutomation: vi.fn(),
},
}));
const localBackend: Backend = {
id: "local-1",
name: "Local 1",
host: "http://localhost:8000",
apiKey: "session-key",
kind: "local",
};
const cloudBackend: Backend = {
id: "cloud-1",
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "bearer-key",
kind: "cloud",
};
const automation: Automation = {
id: "auto-1",
name: "Test",
prompt: "p",
trigger: { type: "schedule", schedule_human: "Daily" },
enabled: true,
repository: "acme/repo",
model: "Claude",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
const listResponse: AutomationsResponse = {
automations: [automation],
total: 1,
};
function makeWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<ActiveBackendProvider>{children}</ActiveBackendProvider>
</QueryClientProvider>
);
};
}
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
vi.mocked(AutomationService.getAutomations).mockReset();
vi.mocked(AutomationService.getAutomation).mockReset();
vi.mocked(AutomationService.getAutomations).mockResolvedValue(listResponse);
vi.mocked(AutomationService.getAutomation).mockResolvedValue(automation);
setRegisteredBackends([localBackend, cloudBackend]);
setActiveSelection({ backendId: localBackend.id });
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
});
describe("automation hooks — backend switch", () => {
it("useAutomations refetches when the active backend changes", async () => {
// Arrange — mount under the local backend; capture the initial fetch.
const { result } = renderHook(() => useAutomations(50, 0), {
wrapper: makeWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(AutomationService.getAutomations).toHaveBeenCalledTimes(1);
// Act — flip the active backend to a cloud one.
setActiveSelection({ backendId: cloudBackend.id });
// Assert — react-query treats the new (backend, org) as a brand-new
// query (the key includes active.backend.id + active.orgId), so a
// second fetch fires automatically without any explicit invalidate.
await waitFor(() => {
expect(AutomationService.getAutomations).toHaveBeenCalledTimes(2);
});
});
it("useAutomationDetail refetches when the active backend changes", async () => {
const { result } = renderHook(() => useAutomationDetail("auto-1"), {
wrapper: makeWrapper(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(AutomationService.getAutomation).toHaveBeenCalledTimes(1);
setActiveSelection({ backendId: cloudBackend.id });
await waitFor(() => {
expect(AutomationService.getAutomation).toHaveBeenCalledTimes(2);
});
});
});
+119
View File
@@ -0,0 +1,119 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import React from "react";
import { render, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter, Route, Routes } from "react-router";
import AutomationService from "#/api/automation-service/automation-service.api";
import {
__resetActiveStoreForTests,
setActiveSelection,
setRegisteredBackends,
} from "#/api/backend-registry/active-store";
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
import AutomationDetail from "#/routes/automation-detail";
import type { Backend } from "#/api/backend-registry/types";
import type {
Automation,
AutomationRunsResponse,
} from "#/types/automation";
vi.mock("#/api/automation-service/automation-service.api", () => ({
default: {
getAutomation: vi.fn(),
getAutomationRuns: vi.fn(),
toggleAutomation: vi.fn(),
deleteAutomation: vi.fn(),
},
}));
const localBackend: Backend = {
id: "local-1",
name: "Local 1",
host: "http://localhost:8000",
apiKey: "session-key",
kind: "local",
};
const cloudBackend: Backend = {
id: "cloud-1",
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "bearer-key",
kind: "cloud",
};
const automation: Automation = {
id: "auto-1",
name: "Test Automation",
prompt: "p",
trigger: { type: "schedule", schedule_human: "Daily" },
enabled: true,
repository: "acme/repo",
model: "Claude",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
const emptyRuns: AutomationRunsResponse = { runs: [], total: 0 };
function renderDetail() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<ActiveBackendProvider>
<MemoryRouter initialEntries={["/automations/auto-1"]}>
<Routes>
<Route
path="/automations/:automationId"
element={<AutomationDetail />}
/>
</Routes>
</MemoryRouter>
</ActiveBackendProvider>
</QueryClientProvider>,
);
}
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
vi.mocked(AutomationService.getAutomation).mockReset();
vi.mocked(AutomationService.getAutomation).mockResolvedValue(automation);
vi.mocked(AutomationService.getAutomationRuns).mockReset();
vi.mocked(AutomationService.getAutomationRuns).mockResolvedValue(emptyRuns);
setRegisteredBackends([localBackend, cloudBackend]);
setActiveSelection({ backendId: localBackend.id });
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
});
describe("AutomationDetail — backend-change guard", () => {
it("does not fetch the automation again when the active backend changes after mount", async () => {
// Arrange — the page mounts under the local backend; the id in the URL
// refers to a local-only automation. Wait for the initial fetch.
renderDetail();
await waitFor(() => {
expect(AutomationService.getAutomation).toHaveBeenCalledTimes(1);
});
expect(AutomationService.getAutomation).toHaveBeenLastCalledWith("auto-1");
// Act — flip the active backend to cloud while the detail page is
// still mounted (the BackendSelector's redirect lands on the next
// tick; the guard must prevent any fetch in this window).
setActiveSelection({ backendId: cloudBackend.id });
// Assert — no second fetch for the now-stale local id is made.
// Give react-query a chance to react to the key change before
// asserting.
await new Promise((resolve) => {
setTimeout(resolve, 50);
});
expect(AutomationService.getAutomation).toHaveBeenCalledTimes(1);
});
});
@@ -5,15 +5,21 @@ import type {
AutomationRunsResponse,
} from "#/types/automation";
import { getAgentServerBaseUrl } from "../agent-server-config";
import { getActiveBackend } from "../backend-registry/active-store";
import { callCloudProxy } from "../cloud/proxy";
const AUTOMATION_BASE_PATH = "/api/automation";
// Create axios instance for automation API with Bearer auth
const automationAxios = axios.create({
// Local automation calls go to the automation sidecar that
// `scripts/dev-with-automation.mjs` mounts behind the bundled agent-server.
// That sidecar authenticates via its own `VITE_AUTOMATION_API_KEY` Bearer
// token — NOT the agent-server's `X-Session-API-Key` — so we cannot reuse
// the shared `openHands` axios for these calls.
const localAutomationAxios = axios.create({
baseURL: getAgentServerBaseUrl(),
});
automationAxios.interceptors.request.use((config) => {
localAutomationAxios.interceptors.request.use((config) => {
const apiKey = import.meta.env.VITE_AUTOMATION_API_KEY?.trim();
if (apiKey) {
config.headers.set("Authorization", `Bearer ${apiKey}`);
@@ -21,16 +27,31 @@ automationAxios.interceptors.request.use((config) => {
return config;
});
function buildPaginationQuery(limit: number, offset: number): string {
const params = new URLSearchParams();
params.set("limit", String(limit));
params.set("offset", String(offset));
return params.toString();
}
class AutomationService {
static async listAutomations(
params: { limit?: number; offset?: number } = {},
): Promise<AutomationsResponse> {
const { limit = 50, offset = 0 } = params;
const { data } = await automationAxios.get<AutomationsResponse>(
const active = getActiveBackend().backend;
if (active.kind === "cloud") {
return callCloudProxy<AutomationsResponse>({
backend: active,
method: "GET",
path: `${AUTOMATION_BASE_PATH}/v1?${buildPaginationQuery(limit, offset)}`,
});
}
const { data } = await localAutomationAxios.get<AutomationsResponse>(
`${AUTOMATION_BASE_PATH}/v1`,
{
params: { limit, offset },
},
{ params: { limit, offset } },
);
return data;
}
@@ -43,9 +64,18 @@ class AutomationService {
}
static async getAutomation(id: string): Promise<Automation> {
const { data } = await automationAxios.get<Automation>(
`${AUTOMATION_BASE_PATH}/v1/${encodeURIComponent(id)}`,
);
const active = getActiveBackend().backend;
const path = `${AUTOMATION_BASE_PATH}/v1/${encodeURIComponent(id)}`;
if (active.kind === "cloud") {
return callCloudProxy<Automation>({
backend: active,
method: "GET",
path,
});
}
const { data } = await localAutomationAxios.get<Automation>(path);
return data;
}
@@ -53,17 +83,36 @@ class AutomationService {
id: string,
body: Partial<Automation>,
): Promise<Automation> {
const { data } = await automationAxios.patch<Automation>(
`${AUTOMATION_BASE_PATH}/v1/${encodeURIComponent(id)}`,
body,
);
const active = getActiveBackend().backend;
const path = `${AUTOMATION_BASE_PATH}/v1/${encodeURIComponent(id)}`;
if (active.kind === "cloud") {
return callCloudProxy<Automation>({
backend: active,
method: "PATCH",
path,
body: body as Record<string, unknown>,
});
}
const { data } = await localAutomationAxios.patch<Automation>(path, body);
return data;
}
static async deleteAutomation(id: string): Promise<void> {
await automationAxios.delete(
`${AUTOMATION_BASE_PATH}/v1/${encodeURIComponent(id)}`,
);
const active = getActiveBackend().backend;
const path = `${AUTOMATION_BASE_PATH}/v1/${encodeURIComponent(id)}`;
if (active.kind === "cloud") {
await callCloudProxy<unknown>({
backend: active,
method: "DELETE",
path,
});
return;
}
await localAutomationAxios.delete(path);
}
static async listAutomationRuns(
@@ -71,8 +120,19 @@ class AutomationService {
params: { limit?: number; offset?: number } = {},
): Promise<AutomationRunsResponse> {
const { limit = 50, offset = 0 } = params;
const { data } = await automationAxios.get<AutomationRunsResponse>(
`${AUTOMATION_BASE_PATH}/v1/${encodeURIComponent(id)}/runs`,
const active = getActiveBackend().backend;
const basePath = `${AUTOMATION_BASE_PATH}/v1/${encodeURIComponent(id)}/runs`;
if (active.kind === "cloud") {
return callCloudProxy<AutomationRunsResponse>({
backend: active,
method: "GET",
path: `${basePath}?${buildPaginationQuery(limit, offset)}`,
});
}
const { data } = await localAutomationAxios.get<AutomationRunsResponse>(
basePath,
{ params: { limit, offset } },
);
return data;
@@ -82,6 +82,7 @@ export function BackendSelector() {
useSwitchCloudOrganization();
const navigate = useNavigate();
const conversationMatch = useMatch("/conversations/:conversationId");
const automationDetailMatch = useMatch("/automations/:automationId");
const bundledLabel = t(I18nKey.BACKEND$LOCAL_ROW);
const personalWorkspaceLabel = t(I18nKey.BACKEND$PERSONAL_WORKSPACE);
@@ -182,15 +183,20 @@ export function BackendSelector() {
}
}
// The current conversation/automation belongs to the previous
// backend and is no longer reachable under the new one — redirect
// BEFORE flipping the active selection so the route change and
// backend change land in the same render batch. Otherwise the
// detail page re-renders once with the new backend (its query
// key includes `active.backend.id`) and react-query fires a
// fetch for the previous backend's id against the new backend.
if (conversationMatch) navigate("/");
else if (automationDetailMatch) navigate("/automations");
// Pure backend swap (local-↔-bundled or backend-only cloud
// selection without an org) skips `/switch` and updates active
// directly; cloud-with-org falls through here after `/switch`.
setActive(backendId, orgId);
// The current conversation belongs to the previous backend
// and is no longer reachable under the new one — redirect home
// so the user lands on a coherent screen.
if (conversationMatch) navigate("/");
}}
placeholder={bundledLabel}
loading={someCloudLoading || isSwitching}
+16 -2
View File
@@ -1,12 +1,19 @@
import { useQuery } from "@tanstack/react-query";
import AutomationService from "#/api/automation-service/automation-service.api";
import { useActiveBackend } from "#/contexts/active-backend-context";
export const AUTOMATION_DETAIL_QUERY_KEY = ["automation-detail"] as const;
export const AUTOMATION_RUNS_QUERY_KEY = ["automation-runs"] as const;
export function useAutomationDetail(id: string) {
const active = useActiveBackend();
return useQuery({
queryKey: [...AUTOMATION_DETAIL_QUERY_KEY, id],
queryKey: [
...AUTOMATION_DETAIL_QUERY_KEY,
id,
active.backend.id,
active.orgId,
],
queryFn: () => AutomationService.getAutomation(id),
staleTime: 5 * 60 * 1000,
enabled: !!id,
@@ -14,8 +21,15 @@ export function useAutomationDetail(id: string) {
}
export function useAutomationRuns(id: string, limit = 20, offset = 0) {
const active = useActiveBackend();
return useQuery({
queryKey: [...AUTOMATION_RUNS_QUERY_KEY, id, { limit, offset }],
queryKey: [
...AUTOMATION_RUNS_QUERY_KEY,
id,
{ limit, offset },
active.backend.id,
active.orgId,
],
queryFn: () => AutomationService.getAutomationRuns(id, limit, offset),
staleTime: 60 * 1000,
enabled: !!id,
+8 -1
View File
@@ -1,12 +1,19 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import AutomationService from "#/api/automation-service/automation-service.api";
import { useActiveBackend } from "#/contexts/active-backend-context";
import { AUTOMATION_DETAIL_QUERY_KEY } from "./use-automation-detail";
export const AUTOMATIONS_QUERY_KEY = ["automations"] as const;
export function useAutomations(limit = 50, offset = 0) {
const active = useActiveBackend();
return useQuery({
queryKey: [...AUTOMATIONS_QUERY_KEY, { limit, offset }],
queryKey: [
...AUTOMATIONS_QUERY_KEY,
{ limit, offset },
active.backend.id,
active.orgId,
],
queryFn: () => AutomationService.getAutomations(limit, offset),
staleTime: 5 * 60 * 1000,
});
+12 -2
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useRef, useState } from "react";
import { useParams } from "react-router";
import { isAxiosError } from "axios";
import { useAutomationDetail } from "#/hooks/query/use-automation-detail";
@@ -6,6 +6,7 @@ import {
useToggleAutomation,
useDeleteAutomation,
} from "#/hooks/query/use-automations";
import { useActiveBackend } from "#/contexts/active-backend-context";
import { useNavigation } from "#/context/navigation-context";
import { BackLink } from "#/components/features/automations/detail/back-link";
import { DetailHeader } from "#/components/features/automations/detail/detail-header";
@@ -24,13 +25,22 @@ export default function AutomationDetail() {
const { navigate } = useNavigation();
const [showDeleteModal, setShowDeleteModal] = useState(false);
// The automationId in the URL belongs to whichever backend was active
// when the page first mounted. If the user switches backends, the id
// is meaningless under the new backend — disable the query so we
// don't fire a request that the backend selector's redirect will
// immediately navigate away from anyway.
const active = useActiveBackend();
const mountedBackendId = useRef(active.backend.id);
const backendChanged = mountedBackendId.current !== active.backend.id;
const {
data: automation,
isLoading,
isError,
error,
refetch,
} = useAutomationDetail(automationId ?? "");
} = useAutomationDetail(backendChanged ? "" : (automationId ?? ""));
const toggleMutation = useToggleAutomation();
const deleteMutation = useDeleteAutomation();