FN-7476: fix desktop engine availability banner
Treat desktop embedded engine managers as automation-capable during startup so the dashboard avoids false remediation. - Report process-level engine availability when a modern manager can lazily start or is starting project engines.\n- Keep project-scoped status details on /api/engine/status while hiding the dashboard-only unavailable banner in desktop mode.\n- Add dashboard health polling, banner, server health, and changeset coverage for the desktop false-banner regression.\n\nFiles changed:\n .changeset/fn-7476-desktop-engine-banner.md | 7 +++++++\n .../dashboard/__tests__/DashboardBanners.test.tsx | 18 ++++++++++++++++\n .../app/hooks/__tests__/useDashboardHealth.test.ts | 24 +++++++++++++++++++++-\n packages/dashboard/src/__tests__/server.test.ts | 24 ++++++++++++++++++----\n packages/dashboard/src/server.ts | 9 +++++---\n 5 files changed, 74 insertions(+), 8 deletions(-) Fusion-Task-Id: FN-7476 Fusion-Task-Lineage: d54a9872-252b-44c5-9c6d-6168b65118c7 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7476-desktop-engine-banner.md
Normal file
7
.changeset/fn-7476-desktop-engine-banner.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Fix a false AI engine not running banner in desktop mode.
|
||||||
|
category: fix
|
||||||
|
dev: Distinguishes transient embedded desktop engine startup from true dashboard-only mode.
|
||||||
@@ -331,6 +331,24 @@ describe("DashboardBanners engine remediation visibility", () => {
|
|||||||
expect(screen.getAllByRole("button", { name: /start engine/i })).toHaveLength(2);
|
expect(screen.getAllByRole("button", { name: /start engine/i })).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not mount the dashboard-only unavailable banner when desktop health reports a startable manager", () => {
|
||||||
|
render(
|
||||||
|
<DashboardBanners
|
||||||
|
{...buildProps({
|
||||||
|
authTokenRecoveryOpen: false,
|
||||||
|
dashboardHealth: {
|
||||||
|
...unavailableEngineHealth(),
|
||||||
|
engine: { available: true },
|
||||||
|
} as DashboardBannersProps["dashboardHealth"],
|
||||||
|
})}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("engine-unavailable-banner")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("engine-status-banner")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByRole("button", { name: /start engine/i })).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves the current project guard while auth recovery is closed", () => {
|
it("preserves the current project guard while auth recovery is closed", () => {
|
||||||
render(<DashboardBanners {...buildProps({ authTokenRecoveryOpen: false, currentProject: null })} />);
|
render(<DashboardBanners {...buildProps({ authTokenRecoveryOpen: false, currentProject: null })} />);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
const fetchDashboardHealth = vi.fn();
|
const fetchDashboardHealth = vi.fn();
|
||||||
@@ -16,6 +16,10 @@ describe("useDashboardHealth", () => {
|
|||||||
refreshDashboardHealth.mockReset();
|
refreshDashboardHealth.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
it("seeds health from the mount fetch and falls back to null on failure", async () => {
|
it("seeds health from the mount fetch and falls back to null on failure", async () => {
|
||||||
fetchDashboardHealth.mockResolvedValue({ status: "ok" });
|
fetchDashboardHealth.mockResolvedValue({ status: "ok" });
|
||||||
const { result } = renderHook(() => useDashboardHealth());
|
const { result } = renderHook(() => useDashboardHealth());
|
||||||
@@ -59,6 +63,24 @@ describe("useDashboardHealth", () => {
|
|||||||
expect(result.current.refreshError).toBe("nope");
|
expect(result.current.refreshError).toBe("nope");
|
||||||
expect(result.current.refreshing).toBe(false);
|
expect(result.current.refreshing).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("polls health so a transient unavailable desktop startup response is replaced", async () => {
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
fetchDashboardHealth
|
||||||
|
.mockResolvedValueOnce({ status: "ok", engine: { available: false } })
|
||||||
|
.mockResolvedValueOnce({ status: "ok", engine: { available: true } });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useDashboardHealth());
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.health).toEqual({ status: "ok", engine: { available: false } }));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(15_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.health).toEqual({ status: "ok", engine: { available: true } }));
|
||||||
|
expect(fetchDashboardHealth).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
it("fires the mount fetch and tolerates an unmount before it resolves", async () => {
|
it("fires the mount fetch and tolerates an unmount before it resolves", async () => {
|
||||||
let resolveMount: (value: { status: string }) => void = () => {};
|
let resolveMount: (value: { status: string }) => void = () => {};
|
||||||
fetchDashboardHealth.mockImplementation(
|
fetchDashboardHealth.mockImplementation(
|
||||||
|
|||||||
@@ -462,14 +462,9 @@ describe("createServer health and headless mode", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports the engine unavailable when the manager has no running engines", async () => {
|
it("reports the engine unavailable when no engine manager can run automation", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const app = createServer(store, {
|
const app = createServer(store);
|
||||||
engineManager: {
|
|
||||||
getAllEngines: vi.fn().mockReturnValue(new Map()),
|
|
||||||
getEngine: vi.fn(),
|
|
||||||
} as any,
|
|
||||||
});
|
|
||||||
|
|
||||||
const res = await GET(app, "/api/health");
|
const res = await GET(app, "/api/health");
|
||||||
|
|
||||||
@@ -477,6 +472,27 @@ describe("createServer health and headless mode", () => {
|
|||||||
expect(res.body.engine).toEqual({ available: false });
|
expect(res.body.engine).toEqual({ available: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps desktop embedded managers available while engines are still starting or lazily startable", async () => {
|
||||||
|
const store = createMockStore();
|
||||||
|
const app = createServer(store, {
|
||||||
|
engineManager: {
|
||||||
|
getAllEngines: vi.fn().mockReturnValue(new Map()),
|
||||||
|
hasRunningEngine: vi.fn().mockReturnValue(false),
|
||||||
|
getEngine: vi.fn(),
|
||||||
|
has: vi.fn((projectId: string) => projectId === "starting"),
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
const health = await GET(app, "/api/health");
|
||||||
|
const startingStatus = await GET(app, "/api/engine/status?projectId=starting");
|
||||||
|
const lazilyStartableStatus = await GET(app, "/api/engine/status?projectId=missing");
|
||||||
|
|
||||||
|
expect(health.status).toBe(200);
|
||||||
|
expect(health.body.engine).toEqual({ available: true });
|
||||||
|
expect(startingStatus.body).toEqual({ connected: false, starting: true, canStart: true, projectId: "starting" });
|
||||||
|
expect(lazilyStartableStatus.body).toEqual({ connected: false, starting: false, canStart: true, projectId: "missing" });
|
||||||
|
});
|
||||||
|
|
||||||
it("reports the engine available when the manager has a running engine", async () => {
|
it("reports the engine available when the manager has a running engine", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const engine = {
|
const engine = {
|
||||||
|
|||||||
@@ -501,11 +501,14 @@ function hasDashboardEngine(options?: ServerOptions): boolean {
|
|||||||
* by this process AND engines owned by another fusion process on the machine
|
* by this process AND engines owned by another fusion process on the machine
|
||||||
* (detected via the singleton lock); without the latter a UI-only launch
|
* (detected via the singleton lock); without the latter a UI-only launch
|
||||||
* alongside an already-running engine shows a false "engine not running"
|
* alongside an already-running engine shows a false "engine not running"
|
||||||
* banner. Fall back to the owned-engine map for older manager instances /
|
* banner.
|
||||||
* test doubles.
|
*
|
||||||
|
* FNXC:DesktopEngineAvailability 2026-07-03-16:15:
|
||||||
|
* Desktop embedded local mode creates modern ProjectEngineManager before any project engine may exist.
|
||||||
|
* Treating an empty owned-engine map as dashboard-only makes the app shell show restart-Fusion remediation while the same manager is still starting, reconciling, or able to lazily ensure the selected project engine. A manager with machine-level liveness support therefore means automation is available at the process level; project-scoped disconnected/starting details stay on /api/engine/status. Older manager test doubles without that method retain the historical owned-engine fallback.
|
||||||
*/
|
*/
|
||||||
if (typeof manager.hasRunningEngine === "function") {
|
if (typeof manager.hasRunningEngine === "function") {
|
||||||
return manager.hasRunningEngine();
|
return true;
|
||||||
}
|
}
|
||||||
const engines = manager.getAllEngines?.();
|
const engines = manager.getAllEngines?.();
|
||||||
return Boolean(engines && engines.size > 0);
|
return Boolean(engines && engines.size > 0);
|
||||||
|
|||||||
Reference in New Issue
Block a user