mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 16:19:05 +08:00
fix: route through active backend on environment switch (automations) (#182)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user