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:
gsxdsm
2026-07-03 19:56:03 -07:00
parent e8b73623d4
commit b4b1f6d7ae
5 changed files with 77 additions and 11 deletions

View 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.

View File

@@ -331,6 +331,24 @@ describe("DashboardBanners engine remediation visibility", () => {
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", () => {
render(<DashboardBanners {...buildProps({ authTokenRecoveryOpen: false, currentProject: null })} />);

View File

@@ -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";
const fetchDashboardHealth = vi.fn();
@@ -16,6 +16,10 @@ describe("useDashboardHealth", () => {
refreshDashboardHealth.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it("seeds health from the mount fetch and falls back to null on failure", async () => {
fetchDashboardHealth.mockResolvedValue({ status: "ok" });
const { result } = renderHook(() => useDashboardHealth());
@@ -59,6 +63,24 @@ describe("useDashboardHealth", () => {
expect(result.current.refreshError).toBe("nope");
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 () => {
let resolveMount: (value: { status: string }) => void = () => {};
fetchDashboardHealth.mockImplementation(

View File

@@ -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 app = createServer(store, {
engineManager: {
getAllEngines: vi.fn().mockReturnValue(new Map()),
getEngine: vi.fn(),
} as any,
});
const app = createServer(store);
const res = await GET(app, "/api/health");
@@ -477,6 +472,27 @@ describe("createServer health and headless mode", () => {
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 () => {
const store = createMockStore();
const engine = {

View File

@@ -501,11 +501,14 @@ function hasDashboardEngine(options?: ServerOptions): boolean {
* 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
* alongside an already-running engine shows a false "engine not running"
* banner. Fall back to the owned-engine map for older manager instances /
* test doubles.
* banner.
*
* 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") {
return manager.hasRunningEngine();
return true;
}
const engines = manager.getAllEngines?.();
return Boolean(engines && engines.size > 0);