FN-6827: add engine status banner
Add project-scoped engine connectivity remediation to the dashboard. - Add engine status/start API helpers and server routes that can resume paused projects or start missing engines. - Render a project banner with dashboard-only guidance, polling, disabled starting state, localized copy, and focused tests. - Document the engine status banner behavior and add a changeset for the published CLI package. Files changed: .changeset/fn-6827-engine-disconnected-banner.md | 7 + docs/dashboard-guide.md | 8 + packages/dashboard/app/api/legacy.ts | 20 +++ .../app/components/EngineStatusBanner.css | 87 +++++++++++ .../app/components/EngineStatusBanner.tsx | 62 ++++++++ .../__tests__/EngineStatusBanner.test.tsx | 145 +++++++++++++++++ .../app/components/dashboard/DashboardBanners.tsx | 3 + .../app/hooks/__tests__/useEngineStatus.test.ts | 171 +++++++++++++++++++++ packages/dashboard/app/hooks/useEngineStatus.ts | 111 +++++++++++++ packages/dashboard/src/__tests__/server.test.ts | 129 ++++++++++++++++ packages/dashboard/src/server.ts | 89 +++++++++++ packages/i18n/locales/en/app.json | 10 ++ packages/i18n/locales/es/app.json | 10 ++ packages/i18n/locales/fr/app.json | 10 ++ packages/i18n/locales/ko/app.json | 10 ++ packages/i18n/locales/zh-CN/app.json | 10 ++ packages/i18n/locales/zh-TW/app.json | 10 ++ 17 files changed, 892 insertions(+) Fusion-Task-Id: FN-6827 Fusion-Task-Lineage: 56821ca3-04c8-4674-9400-3639f10e463d
This commit is contained in:
7
.changeset/fn-6827-engine-disconnected-banner.md
Normal file
7
.changeset/fn-6827-engine-disconnected-banner.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Add an engine-disconnected dashboard banner with one-click Start engine.
|
||||
category: feature
|
||||
dev: Adds project-scoped engine status/start API routes and dashboard-only guidance for UI-only launches.
|
||||
@@ -974,6 +974,14 @@ Use this panel when upgrading a project with pre-FN-6245/FN-6277 in-review rows
|
||||
|
||||
The global AI engine stop/start control and triage pause/resume control live in the executor footer status bar rather than the header. Select the small engine-controls button beside the executor state badge, or select the state text such as **Running**, to open the footer popover. The popover includes **Stop AI engine** / **Start AI engine**, **Pause triage** / **Resume scheduling**, and live scheduler sliders for max concurrent tasks, max triage concurrency, and max worktrees. Slider changes save through the existing `/api/settings` path with the same debounced behavior used by Command Center controls; no separate backend route is required.
|
||||
|
||||
### Engine status banner
|
||||
|
||||
When a project dashboard is open but no project engine is connected, Fusion shows a sticky **Engine disconnected** banner above the project content. This covers paused projects, failed or still-starting project engines, delayed reconciliation, and dashboard-only/dev launches where the UI is available before an engine manager is attached.
|
||||
|
||||
If the server can start the current project engine, use **Start engine** in the banner to resume a paused project or call the project engine startup path without reloading the dashboard. While the start request is in flight the button is disabled and shows the starting state so repeated clicks cannot create duplicate startup attempts. The banner disappears as soon as the status endpoint reports the project engine is connected.
|
||||
|
||||
If the dashboard is running without engine management, the banner stays informational and disables the start action. Start the full server with `fn serve` to enable one-click engine startup and live task execution.
|
||||
|
||||
### Identifying high-impact blockers
|
||||
|
||||
Use blocker fan-out signals on task cards and in the footer status bar to spot blockers with high downstream impact:
|
||||
|
||||
@@ -255,6 +255,26 @@ export function refreshDashboardHealth(): Promise<DashboardHealthResponse> {
|
||||
return api<DashboardHealthResponse>("/health/refresh", { method: "POST" });
|
||||
}
|
||||
|
||||
export interface EngineStatusResponse {
|
||||
connected: boolean;
|
||||
starting: boolean;
|
||||
canStart: boolean;
|
||||
reason?: "dashboard-only" | "no-project" | string;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
/*
|
||||
* FNXC:EngineStatusBanner 2026-06-22-00:00:
|
||||
* Engine status is project-scoped because a multi-project dashboard can have one running engine while the current project is paused, failed, or not yet started. Thread `projectId` through the existing query helper so the server resolves the same project context as task and settings routes.
|
||||
*/
|
||||
export function fetchEngineStatus(projectId?: string): Promise<EngineStatusResponse> {
|
||||
return api<EngineStatusResponse>(withProjectId("/engine/status", projectId));
|
||||
}
|
||||
|
||||
export function startEngine(projectId?: string): Promise<EngineStatusResponse> {
|
||||
return api<EngineStatusResponse>(withProjectId("/engine/start", projectId), { method: "POST" });
|
||||
}
|
||||
|
||||
export function checkForUpdates(): Promise<UpdateCheckResponse> {
|
||||
return api<UpdateCheckResponse>("/updates/check");
|
||||
}
|
||||
|
||||
87
packages/dashboard/app/components/EngineStatusBanner.css
Normal file
87
packages/dashboard/app/components/EngineStatusBanner.css
Normal file
@@ -0,0 +1,87 @@
|
||||
.engine-status-banner {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-md);
|
||||
margin: var(--space-md) var(--space-lg) 0;
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--color-warning) 12%, transparent),
|
||||
color-mix(in srgb, var(--surface) 92%, transparent)
|
||||
);
|
||||
border: var(--btn-border-width) solid color-mix(in srgb, var(--color-warning) 35%, transparent);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.engine-status-banner__indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding-top: calc(var(--space-xs) / 2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.engine-status-banner__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.engine-status-banner__title {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.engine-status-banner__body,
|
||||
.engine-status-banner__error {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.engine-status-banner__body code {
|
||||
background: color-mix(in srgb, var(--text) 6%, transparent);
|
||||
padding: 0 var(--space-xs);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.engine-status-banner__error {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.engine-status-banner__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.engine-status-banner__start {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.engine-status-banner {
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
margin-inline: var(--space-md);
|
||||
}
|
||||
|
||||
.engine-status-banner__actions,
|
||||
.engine-status-banner__start {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.engine-status-banner__actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.engine-status-banner__start {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
62
packages/dashboard/app/components/EngineStatusBanner.tsx
Normal file
62
packages/dashboard/app/components/EngineStatusBanner.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEngineStatus } from "../hooks/useEngineStatus";
|
||||
import "./EngineStatusBanner.css";
|
||||
|
||||
interface EngineStatusBannerProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
/*
|
||||
* FNXC:EngineStatusBanner 2026-06-22-00:00:
|
||||
* The project banner stack needs a single visible remediation when the dashboard is loaded but the current project's engine is absent. Hide the entire component once `connected` is true so no empty wrapper, button shell, or stale aria surface remains in the DOM.
|
||||
*/
|
||||
export function EngineStatusBanner({ projectId }: EngineStatusBannerProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const { status, canStart, starting, error, start } = useEngineStatus(projectId);
|
||||
|
||||
if (!status || status.connected) return null;
|
||||
|
||||
const isDashboardOnly = status.reason === "dashboard-only" || status.reason === "unreachable";
|
||||
const statusDotClass = starting ? "status-dot status-dot--connecting" : "status-dot status-dot--error";
|
||||
const body = canStart
|
||||
? t("engineBanner.body", "The engine for this project is not running, so task automation and live updates may be paused. Start it now to reconnect the dashboard.")
|
||||
: t("engineBanner.dashboardOnly", "This dashboard cannot start engines from the current process. Run `fn serve` for this project to enable task execution and live automation.");
|
||||
|
||||
return (
|
||||
<section className="engine-status-banner" role="status" aria-live="polite" data-testid="engine-status-banner">
|
||||
<div className="engine-status-banner__indicator" aria-hidden="true">
|
||||
<span className={statusDotClass} />
|
||||
</div>
|
||||
<div className="engine-status-banner__content">
|
||||
<div className="engine-status-banner__title">{t("engineBanner.title", "Project engine is not connected")}</div>
|
||||
<p className="engine-status-banner__body">
|
||||
{isDashboardOnly ? (
|
||||
<>
|
||||
{t("engineBanner.dashboardOnlyPrefix", "This dashboard cannot start engines from the current process. Run")} <code>fn serve</code> {t("engineBanner.dashboardOnlySuffix", "for this project to enable task execution and live automation.")}
|
||||
</>
|
||||
) : body}
|
||||
</p>
|
||||
{error && (
|
||||
<p className="engine-status-banner__error" role="alert">
|
||||
{t("engineBanner.error", "Start failed: {{message}}", { message: error })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="engine-status-banner__actions">
|
||||
{canStart ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm engine-status-banner__start"
|
||||
onClick={() => void start()}
|
||||
disabled={starting}
|
||||
data-testid="engine-status-start-button"
|
||||
>
|
||||
{starting ? <Loader2 className="spinner" aria-hidden="true" /> : null}
|
||||
{starting ? t("engineBanner.starting", "Starting…") : t("engineBanner.startCta", "Start engine")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EngineStatusBanner } from "../EngineStatusBanner";
|
||||
import * as api from "../../api";
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (_key: string, fallback?: string, values?: Record<string, string>) => {
|
||||
let text = fallback ?? _key;
|
||||
if (values) {
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
text = text.replace(`{{${key}}}`, value);
|
||||
}
|
||||
}
|
||||
return text;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchEngineStatus: vi.fn(),
|
||||
startEngine: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchEngineStatus = vi.mocked(api.fetchEngineStatus);
|
||||
const mockStartEngine = vi.mocked(api.startEngine);
|
||||
|
||||
async function flushPromises(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("EngineStatusBanner", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
mockFetchEngineStatus.mockReset();
|
||||
mockStartEngine.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders no banner, button, or aria-live shell when the engine is connected", async () => {
|
||||
mockFetchEngineStatus.mockResolvedValueOnce({ connected: true, starting: false, canStart: true, projectId: "project-a" });
|
||||
|
||||
const { queryByTestId, container } = render(<EngineStatusBanner projectId="project-a" />);
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(queryByTestId("engine-status-banner")).toBeNull();
|
||||
expect(queryByTestId("engine-status-start-button")).toBeNull();
|
||||
expect(container.querySelector('[aria-live="polite"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("shows an enabled Start engine button when disconnected and startable", async () => {
|
||||
mockFetchEngineStatus.mockResolvedValueOnce({ connected: false, starting: false, canStart: true, projectId: "project-a" });
|
||||
|
||||
render(<EngineStatusBanner projectId="project-a" />);
|
||||
|
||||
expect(await screen.findByTestId("engine-status-banner")).toBeInTheDocument();
|
||||
const button = screen.getByTestId("engine-status-start-button");
|
||||
expect(button).toBeEnabled();
|
||||
expect(button).toHaveTextContent("Start engine");
|
||||
expect(screen.getByText("Project engine is not connected")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clicking Start engine calls startEngine and refetches status", async () => {
|
||||
mockFetchEngineStatus
|
||||
.mockResolvedValueOnce({ connected: false, starting: false, canStart: true, projectId: "project-a" })
|
||||
.mockResolvedValueOnce({ connected: true, starting: false, canStart: true, projectId: "project-a" });
|
||||
mockStartEngine.mockResolvedValueOnce({ connected: false, starting: true, canStart: true, projectId: "project-a" });
|
||||
|
||||
const { queryByTestId, container } = render(<EngineStatusBanner projectId="project-a" />);
|
||||
const button = await screen.findByTestId("engine-status-start-button");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(button);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(mockStartEngine).toHaveBeenCalledWith("project-a");
|
||||
expect(mockFetchEngineStatus).toHaveBeenCalledTimes(2);
|
||||
await waitFor(() => expect(queryByTestId("engine-status-banner")).toBeNull());
|
||||
expect(queryByTestId("engine-status-start-button")).toBeNull();
|
||||
expect(container.querySelector('[aria-live="polite"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("disables the Start engine button while the engine is starting", async () => {
|
||||
mockFetchEngineStatus.mockResolvedValueOnce({ connected: false, starting: true, canStart: true, projectId: "project-a" });
|
||||
|
||||
render(<EngineStatusBanner projectId="project-a" />);
|
||||
|
||||
const button = await screen.findByTestId("engine-status-start-button");
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveTextContent("Starting…");
|
||||
});
|
||||
|
||||
it("shows dashboard-only guidance with no start button when the server cannot start engines", async () => {
|
||||
mockFetchEngineStatus.mockResolvedValueOnce({ connected: false, starting: false, canStart: false, reason: "dashboard-only", projectId: "project-a" });
|
||||
|
||||
render(<EngineStatusBanner projectId="project-a" />);
|
||||
|
||||
expect(await screen.findByTestId("engine-status-banner")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("engine-status-start-button")).toBeNull();
|
||||
expect(screen.getByText("fn serve")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("treats unreachable status probes as disconnected guidance without an enabled action", async () => {
|
||||
mockFetchEngineStatus.mockRejectedValueOnce(new Error("offline"));
|
||||
|
||||
render(<EngineStatusBanner projectId="project-a" />);
|
||||
|
||||
expect(await screen.findByTestId("engine-status-banner")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("engine-status-start-button")).toBeNull();
|
||||
expect(screen.getByText("fn serve")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders retryable start errors inline", async () => {
|
||||
mockFetchEngineStatus.mockResolvedValueOnce({ connected: false, starting: false, canStart: true, projectId: "project-a" });
|
||||
mockStartEngine.mockRejectedValueOnce(new Error("engine failed"));
|
||||
|
||||
render(<EngineStatusBanner projectId="project-a" />);
|
||||
const button = await screen.findByTestId("engine-status-start-button");
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(button);
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Start failed: engine failed");
|
||||
expect(screen.getByTestId("engine-status-start-button")).toBeEnabled();
|
||||
});
|
||||
|
||||
it("ships responsive mobile scaffolding for the banner stack", () => {
|
||||
const css = readFileSync(resolve(__dirname, "..", "EngineStatusBanner.css"), "utf8");
|
||||
|
||||
expect(css).toContain("@media (max-width: 768px)");
|
||||
expect(css).toContain(".engine-status-banner__start");
|
||||
expect(css).toContain("width: 100%");
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import type { DashboardBannersProps } from "./types";
|
||||
import type { SectionId } from "../SettingsModal";
|
||||
import { TestModeBanner } from "../TestModeBanner";
|
||||
import { EngineUnavailableBanner } from "../EngineUnavailableBanner";
|
||||
import { EngineStatusBanner } from "../EngineStatusBanner";
|
||||
import { OAuthReloginBanner } from "../OAuthReloginBanner";
|
||||
import { SessionNotificationBanner } from "../SessionNotificationBanner";
|
||||
import { CliBinaryInstallBanner } from "../CliBinaryInstallBanner";
|
||||
@@ -66,6 +67,8 @@ export function DashboardBanners({
|
||||
<>
|
||||
<TestModeBanner isActive={isTestMode} />
|
||||
<EngineUnavailableBanner isVisible={dashboardHealth?.engine?.available === false} />
|
||||
{/* FNXC:EngineStatusBanner 2026-06-22-00:00: Project-scoped engine remediation belongs in the same project-only banner guard family as the existing operational notices, and the key resets polling immediately when the user switches projects. */}
|
||||
<EngineStatusBanner key={currentProject.id} projectId={currentProject.id} />
|
||||
<OAuthReloginBanner
|
||||
onReLogin={(_providerId) => openSettingsWithNav("authentication" as SectionId)}
|
||||
/>
|
||||
|
||||
171
packages/dashboard/app/hooks/__tests__/useEngineStatus.test.ts
Normal file
171
packages/dashboard/app/hooks/__tests__/useEngineStatus.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useEngineStatus } from "../useEngineStatus";
|
||||
import * as api from "../../api";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchEngineStatus: vi.fn(),
|
||||
startEngine: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchEngineStatus = vi.mocked(api.fetchEngineStatus);
|
||||
const mockStartEngine = vi.mocked(api.startEngine);
|
||||
|
||||
async function flushPromises(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
}
|
||||
|
||||
describe("useEngineStatus", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
mockFetchEngineStatus.mockReset();
|
||||
mockStartEngine.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("fetches project-scoped engine status on mount", async () => {
|
||||
mockFetchEngineStatus.mockResolvedValueOnce({ connected: false, starting: false, canStart: true, projectId: "project-a" });
|
||||
|
||||
const { result } = renderHook(() => useEngineStatus("project-a"));
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(mockFetchEngineStatus).toHaveBeenCalledWith("project-a");
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.status).toEqual({ connected: false, starting: false, canStart: true, projectId: "project-a" });
|
||||
expect(result.current.canStart).toBe(true);
|
||||
expect(result.current.starting).toBe(false);
|
||||
});
|
||||
|
||||
it("does not fetch or render stale status when no project is selected", async () => {
|
||||
const { result } = renderHook(() => useEngineStatus(undefined));
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
await vi.advanceTimersByTimeAsync(10000);
|
||||
});
|
||||
|
||||
expect(mockFetchEngineStatus).not.toHaveBeenCalled();
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.status).toBeNull();
|
||||
expect(result.current.canStart).toBe(false);
|
||||
});
|
||||
|
||||
it("polls while disconnected and pauses polling once connected", async () => {
|
||||
mockFetchEngineStatus
|
||||
.mockResolvedValueOnce({ connected: false, starting: false, canStart: true, projectId: "project-a" })
|
||||
.mockResolvedValueOnce({ connected: true, starting: false, canStart: true, projectId: "project-a" });
|
||||
|
||||
const { result } = renderHook(() => useEngineStatus("project-a"));
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
expect(result.current.status?.connected).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(10000);
|
||||
await flushPromises();
|
||||
});
|
||||
expect(result.current.status?.connected).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(30000);
|
||||
await flushPromises();
|
||||
});
|
||||
expect(mockFetchEngineStatus).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("treats status fetch failures as disconnected without enabling start", async () => {
|
||||
mockFetchEngineStatus.mockRejectedValueOnce(new Error("network down"));
|
||||
|
||||
const { result } = renderHook(() => useEngineStatus("project-a"));
|
||||
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(result.current.status).toEqual({ connected: false, starting: false, canStart: false, reason: "unreachable", projectId: "project-a" });
|
||||
expect(result.current.canStart).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("starts the engine, exposes local in-flight state, and immediately refetches", async () => {
|
||||
mockFetchEngineStatus
|
||||
.mockResolvedValueOnce({ connected: false, starting: false, canStart: true, projectId: "project-a" })
|
||||
.mockResolvedValueOnce({ connected: true, starting: false, canStart: true, projectId: "project-a" });
|
||||
let resolveStart!: (status: { connected: boolean; starting: boolean; canStart: boolean; projectId: string }) => void;
|
||||
mockStartEngine.mockReturnValueOnce(new Promise((resolve) => {
|
||||
resolveStart = resolve;
|
||||
}));
|
||||
|
||||
const { result } = renderHook(() => useEngineStatus("project-a"));
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
let startPromise!: Promise<void>;
|
||||
await act(async () => {
|
||||
startPromise = result.current.start();
|
||||
await flushPromises();
|
||||
});
|
||||
expect(result.current.starting).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
resolveStart({ connected: false, starting: true, canStart: true, projectId: "project-a" });
|
||||
await startPromise;
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(mockStartEngine).toHaveBeenCalledWith("project-a");
|
||||
expect(mockFetchEngineStatus).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.status).toEqual({ connected: true, starting: false, canStart: true, projectId: "project-a" });
|
||||
expect(result.current.starting).toBe(false);
|
||||
});
|
||||
|
||||
it("surfaces start failures without throwing away the disconnected status", async () => {
|
||||
mockFetchEngineStatus.mockResolvedValueOnce({ connected: false, starting: false, canStart: true, projectId: "project-a" });
|
||||
mockStartEngine.mockRejectedValueOnce(new Error("start failed"));
|
||||
|
||||
const { result } = renderHook(() => useEngineStatus("project-a"));
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.start();
|
||||
});
|
||||
|
||||
expect(result.current.error).toBe("start failed");
|
||||
expect(result.current.status).toEqual({ connected: false, starting: false, canStart: true, projectId: "project-a" });
|
||||
expect(result.current.starting).toBe(false);
|
||||
});
|
||||
|
||||
it("immediately refetches when the project changes", async () => {
|
||||
mockFetchEngineStatus
|
||||
.mockResolvedValueOnce({ connected: false, starting: false, canStart: true, projectId: "project-a" })
|
||||
.mockResolvedValueOnce({ connected: true, starting: false, canStart: true, projectId: "project-b" });
|
||||
|
||||
const { result, rerender } = renderHook(({ projectId }) => useEngineStatus(projectId), {
|
||||
initialProps: { projectId: "project-a" },
|
||||
});
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
expect(result.current.status?.projectId).toBe("project-a");
|
||||
|
||||
rerender({ projectId: "project-b" });
|
||||
await act(async () => {
|
||||
await flushPromises();
|
||||
});
|
||||
|
||||
expect(mockFetchEngineStatus).toHaveBeenNthCalledWith(2, "project-b");
|
||||
expect(result.current.status).toEqual({ connected: true, starting: false, canStart: true, projectId: "project-b" });
|
||||
});
|
||||
});
|
||||
111
packages/dashboard/app/hooks/useEngineStatus.ts
Normal file
111
packages/dashboard/app/hooks/useEngineStatus.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { fetchEngineStatus, startEngine, type EngineStatusResponse } from "../api";
|
||||
|
||||
const POLL_INTERVAL_MS = 10000;
|
||||
|
||||
const DISCONNECTED_UNREACHABLE_STATUS: EngineStatusResponse = {
|
||||
connected: false,
|
||||
starting: false,
|
||||
canStart: false,
|
||||
reason: "unreachable",
|
||||
};
|
||||
|
||||
export interface UseEngineStatusResult {
|
||||
status: EngineStatusResponse | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
canStart: boolean;
|
||||
starting: boolean;
|
||||
refetch: () => Promise<void>;
|
||||
start: () => Promise<void>;
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
/*
|
||||
* FNXC:EngineStatusBanner 2026-06-22-00:00:
|
||||
* The banner must never leave the board silently inert. Poll while the current project is disconnected, stop once connected to avoid steady-state traffic, and fold local Start engine clicks into `starting` so the button cannot be double-triggered before the server reports its transient starting state.
|
||||
*/
|
||||
export function useEngineStatus(projectId?: string): UseEngineStatusResult {
|
||||
const [status, setStatus] = useState<EngineStatusResponse | null>(null);
|
||||
const [loading, setLoading] = useState(Boolean(projectId));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [startInFlight, setStartInFlight] = useState(false);
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
const requestId = requestIdRef.current + 1;
|
||||
requestIdRef.current = requestId;
|
||||
|
||||
if (!projectId) {
|
||||
setStatus(null);
|
||||
setLoading(false);
|
||||
setStartInFlight(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const nextStatus = await fetchEngineStatus(projectId);
|
||||
if (requestIdRef.current !== requestId) return;
|
||||
setStatus(nextStatus);
|
||||
if (nextStatus.connected) {
|
||||
setStartInFlight(false);
|
||||
}
|
||||
} catch {
|
||||
if (requestIdRef.current !== requestId) return;
|
||||
setStatus({ ...DISCONNECTED_UNREACHABLE_STATUS, projectId });
|
||||
} finally {
|
||||
if (requestIdRef.current === requestId) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
|
||||
setStartInFlight(true);
|
||||
setError(null);
|
||||
try {
|
||||
const startedStatus = await startEngine(projectId);
|
||||
setStatus(startedStatus);
|
||||
await refetch();
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err));
|
||||
} finally {
|
||||
setStartInFlight(false);
|
||||
}
|
||||
}, [projectId, refetch]);
|
||||
|
||||
useEffect(() => {
|
||||
setStatus(null);
|
||||
setError(null);
|
||||
setStartInFlight(false);
|
||||
void refetch();
|
||||
}, [refetch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId || status?.connected) return;
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
void refetch();
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [projectId, refetch, status?.connected]);
|
||||
|
||||
return useMemo(() => ({
|
||||
status,
|
||||
loading,
|
||||
error,
|
||||
canStart: Boolean(status?.canStart),
|
||||
starting: Boolean(status?.starting || startInFlight),
|
||||
refetch,
|
||||
start,
|
||||
}), [error, loading, refetch, start, startInFlight, status]);
|
||||
}
|
||||
@@ -451,6 +451,135 @@ describe("createServer health and headless mode", () => {
|
||||
expect(res.body.engine).toEqual({ available: true });
|
||||
});
|
||||
|
||||
it("reports project engine status for connected, disconnected, starting, dashboard-only, and no-project states", async () => {
|
||||
const store = createMockStore();
|
||||
const runningEngine = { getTaskStore: vi.fn() };
|
||||
const engineManager = {
|
||||
getEngine: vi.fn((projectId: string) => (projectId === "connected" ? runningEngine : undefined)),
|
||||
has: vi.fn((projectId: string) => projectId === "connected" || projectId === "starting"),
|
||||
ensureEngine: vi.fn(),
|
||||
resumeProject: vi.fn(),
|
||||
getAllEngines: vi.fn().mockReturnValue(new Map()),
|
||||
};
|
||||
const app = createServer(store, { engineManager: engineManager as any });
|
||||
|
||||
const connected = await GET(app, "/api/engine/status?projectId=connected");
|
||||
const disconnected = await GET(app, "/api/engine/status?projectId=missing");
|
||||
const starting = await GET(app, "/api/engine/status?projectId=starting");
|
||||
const noProject = await GET(app, "/api/engine/status");
|
||||
const dashboardOnly = await GET(createServer(store), "/api/engine/status?projectId=missing");
|
||||
|
||||
expect(connected.body).toEqual({ connected: true, starting: false, canStart: true, projectId: "connected" });
|
||||
expect(disconnected.body).toEqual({ connected: false, starting: false, canStart: true, projectId: "missing" });
|
||||
expect(starting.body).toEqual({ connected: false, starting: true, canStart: true, projectId: "starting" });
|
||||
expect(noProject.body).toEqual({ connected: false, starting: false, canStart: false, reason: "no-project" });
|
||||
expect(dashboardOnly.body).toEqual({ connected: false, starting: false, canStart: false, reason: "dashboard-only", projectId: "missing" });
|
||||
});
|
||||
|
||||
it("starts an active project engine and returns the updated status", async () => {
|
||||
const store = createMockStore();
|
||||
let engine: unknown;
|
||||
const engineManager = {
|
||||
getEngine: vi.fn(() => engine),
|
||||
has: vi.fn(() => Boolean(engine)),
|
||||
ensureEngine: vi.fn(async () => {
|
||||
engine = { getTaskStore: vi.fn() };
|
||||
return engine;
|
||||
}),
|
||||
resumeProject: vi.fn(),
|
||||
getAllEngines: vi.fn().mockReturnValue(new Map()),
|
||||
};
|
||||
const centralCore = { getProject: vi.fn().mockResolvedValue({ id: "project-a", status: "active" }) };
|
||||
const app = createServer(store, { engineManager: engineManager as any, centralCore: centralCore as any });
|
||||
|
||||
const res = await REQUEST(app, "POST", "/api/engine/start", JSON.stringify({ projectId: "project-a" }), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(engineManager.ensureEngine).toHaveBeenCalledWith("project-a");
|
||||
expect(engineManager.resumeProject).not.toHaveBeenCalled();
|
||||
expect(res.body).toEqual({ connected: true, starting: false, canStart: true, projectId: "project-a" });
|
||||
});
|
||||
|
||||
it("resumes a paused project before returning engine status", async () => {
|
||||
const store = createMockStore();
|
||||
let engine: unknown;
|
||||
const engineManager = {
|
||||
getEngine: vi.fn(() => engine),
|
||||
has: vi.fn(() => Boolean(engine)),
|
||||
ensureEngine: vi.fn(),
|
||||
resumeProject: vi.fn(async () => {
|
||||
engine = { getTaskStore: vi.fn() };
|
||||
}),
|
||||
getAllEngines: vi.fn().mockReturnValue(new Map()),
|
||||
};
|
||||
const centralCore = { getProject: vi.fn().mockResolvedValue({ id: "project-paused", status: "paused" }) };
|
||||
const app = createServer(store, { engineManager: engineManager as any, centralCore: centralCore as any });
|
||||
|
||||
const res = await REQUEST(app, "POST", "/api/engine/start", JSON.stringify({ projectId: "project-paused" }), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(engineManager.resumeProject).toHaveBeenCalledWith("project-paused");
|
||||
expect(engineManager.ensureEngine).not.toHaveBeenCalled();
|
||||
expect(res.body).toEqual({ connected: true, starting: false, canStart: true, projectId: "project-paused" });
|
||||
});
|
||||
|
||||
it("rejects engine start when dashboard-only or project scope is missing", async () => {
|
||||
const store = createMockStore();
|
||||
const dashboardOnly = await REQUEST(createServer(store), "POST", "/api/engine/start?projectId=project-a");
|
||||
const noProject = await REQUEST(createServer(store, {
|
||||
engineManager: {
|
||||
getEngine: vi.fn(),
|
||||
has: vi.fn(),
|
||||
ensureEngine: vi.fn(),
|
||||
resumeProject: vi.fn(),
|
||||
getAllEngines: vi.fn().mockReturnValue(new Map()),
|
||||
} as any,
|
||||
}), "POST", "/api/engine/start");
|
||||
|
||||
expect(dashboardOnly.status).toBe(409);
|
||||
expect(dashboardOnly.body).toEqual({ error: "Engine manager is unavailable", reason: "dashboard-only" });
|
||||
expect(noProject.status).toBe(409);
|
||||
expect(noProject.body).toEqual({ error: "Project id is required", reason: "no-project" });
|
||||
});
|
||||
|
||||
it("returns sanitized start failures without leaking stack traces", async () => {
|
||||
const store = createMockStore();
|
||||
const engineManager = {
|
||||
getEngine: vi.fn(),
|
||||
has: vi.fn().mockReturnValue(false),
|
||||
ensureEngine: vi.fn().mockRejectedValue(new Error("Project project-a disappeared")),
|
||||
resumeProject: vi.fn(),
|
||||
getAllEngines: vi.fn().mockReturnValue(new Map()),
|
||||
};
|
||||
const app = createServer(store, { engineManager: engineManager as any });
|
||||
|
||||
const res = await REQUEST(app, "POST", "/api/engine/start?projectId=project-a");
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body).toEqual({ error: "Project project-a disappeared" });
|
||||
});
|
||||
|
||||
it("returns a paused conflict when ensureEngine hits the paused guard", async () => {
|
||||
const store = createMockStore();
|
||||
const engineManager = {
|
||||
getEngine: vi.fn(),
|
||||
has: vi.fn().mockReturnValue(false),
|
||||
ensureEngine: vi.fn().mockRejectedValue(new Error("Project project-a is paused")),
|
||||
resumeProject: vi.fn(),
|
||||
getAllEngines: vi.fn().mockReturnValue(new Map()),
|
||||
};
|
||||
const app = createServer(store, { engineManager: engineManager as any });
|
||||
|
||||
const res = await REQUEST(app, "POST", "/api/engine/start?projectId=project-a");
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body).toEqual({ error: "Project project-a is paused", reason: "paused" });
|
||||
});
|
||||
|
||||
it("reports degraded status when database corruption is detected", async () => {
|
||||
const store = createMockStore({
|
||||
getDatabaseHealth: vi.fn().mockReturnValue({
|
||||
|
||||
@@ -63,6 +63,7 @@ import type { SkillsAdapter } from "./skills-adapter.js";
|
||||
import { createAuthMiddleware, authenticateUpgradeRequest, getDaemonToken } from "./auth-middleware.js";
|
||||
import { setupCliSessionWebSocket } from "./cli-session-ws.js";
|
||||
import { createCliSessionsRouter } from "./routes/cli-sessions.js";
|
||||
import { getProjectIdFromRequest } from "./routes/context.js";
|
||||
import type { CliRelaunchRegistry } from "./cli-session-transport.js";
|
||||
import { validateRemoteAuthToken } from "./remote-auth.js";
|
||||
import { getCliPackageVersion } from "./cli-package-version.js";
|
||||
@@ -499,6 +500,56 @@ function hasDashboardEngine(options?: ServerOptions): boolean {
|
||||
return Boolean(engines && engines.size > 0);
|
||||
}
|
||||
|
||||
export type EngineStatusReason = "dashboard-only" | "no-project";
|
||||
|
||||
export interface EngineStatusPayload {
|
||||
connected: boolean;
|
||||
starting: boolean;
|
||||
canStart: boolean;
|
||||
reason?: EngineStatusReason;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
function buildEngineStatusPayload(projectId: string | undefined, options?: ServerOptions): EngineStatusPayload {
|
||||
const engineManager = options?.engineManager;
|
||||
const base = projectId ? { projectId } : {};
|
||||
|
||||
if (!engineManager) {
|
||||
return {
|
||||
connected: false,
|
||||
starting: false,
|
||||
canStart: false,
|
||||
reason: "dashboard-only",
|
||||
...base,
|
||||
};
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
return {
|
||||
connected: false,
|
||||
starting: false,
|
||||
canStart: false,
|
||||
reason: "no-project",
|
||||
};
|
||||
}
|
||||
|
||||
const engine = engineManager.getEngine(projectId);
|
||||
/*
|
||||
* FNXC:EngineStatusBanner 2026-06-22-00:00:
|
||||
* The dashboard needs a project-scoped distinction between a missing engine and an engine start already in flight. `has(projectId) && !getEngine(projectId)` mirrors ProjectEngineManager's transient starting map so the UI can disable duplicate Start engine attempts while reconciliation or a prior click is still creating the engine.
|
||||
*/
|
||||
return {
|
||||
connected: Boolean(engine),
|
||||
starting: Boolean(engineManager.has(projectId) && !engine),
|
||||
canStart: true,
|
||||
projectId,
|
||||
};
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
type DashboardExpressApp = ReturnType<typeof express> & {
|
||||
terminalWsServer?: WebSocketServer | null;
|
||||
badgeWsServer?: WebSocketServer | null;
|
||||
@@ -1452,6 +1503,44 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
||||
}));
|
||||
});
|
||||
|
||||
app.get("/api/engine/status", (req, res) => {
|
||||
const projectId = getProjectIdFromRequest(req);
|
||||
res.json(buildEngineStatusPayload(projectId, options));
|
||||
});
|
||||
|
||||
app.post("/api/engine/start", async (req, res) => {
|
||||
const projectId = getProjectIdFromRequest(req);
|
||||
const engineManager = options?.engineManager;
|
||||
|
||||
if (!engineManager) {
|
||||
res.status(409).json({ error: "Engine manager is unavailable", reason: "dashboard-only" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
res.status(409).json({ error: "Project id is required", reason: "no-project" });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
/*
|
||||
* FNXC:EngineStatusBanner 2026-06-22-00:00:
|
||||
* The one-click Start engine action must also recover intentionally paused projects. `ensureEngine` refuses paused projects by design, so the route checks CentralCore first and uses `resumeProject` for paused status while keeping active projects on the normal `ensureEngine` path.
|
||||
*/
|
||||
const project = await options?.centralCore?.getProject(projectId);
|
||||
if (project && (project.status as string) === "paused") {
|
||||
await engineManager.resumeProject(projectId);
|
||||
} else {
|
||||
await engineManager.ensureEngine(projectId);
|
||||
}
|
||||
res.json(buildEngineStatusPayload(projectId, options));
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
const isPausedGuard = message === `Project ${projectId} is paused`;
|
||||
res.status(isPausedGuard ? 409 : 500).json({ error: message, reason: isPausedGuard ? "paused" : undefined });
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/health/reliability", async (req, res) => {
|
||||
const rawWindowDays = req.query.windowDays;
|
||||
const parsedWindowDays = rawWindowDays === undefined ? 7 : Number.parseInt(String(rawWindowDays), 10);
|
||||
|
||||
@@ -31,6 +31,16 @@
|
||||
"update": "Update",
|
||||
"yes": "Yes"
|
||||
},
|
||||
"engineBanner": {
|
||||
"body": "The engine for this project is not running, so task automation and live updates may be paused. Start it now to reconnect the dashboard.",
|
||||
"dashboardOnly": "This dashboard cannot start engines from the current process. Run `fn serve` for this project to enable task execution and live automation.",
|
||||
"dashboardOnlyPrefix": "This dashboard cannot start engines from the current process. Run",
|
||||
"dashboardOnlySuffix": "for this project to enable task execution and live automation.",
|
||||
"error": "Start failed: {{message}}",
|
||||
"startCta": "Start engine",
|
||||
"starting": "Starting…",
|
||||
"title": "Project engine is not connected"
|
||||
},
|
||||
"activityFeed": {
|
||||
"emptyHint": "Activity will appear here when tasks are created, moved, or completed",
|
||||
"eventCount_one": "{{count}} event",
|
||||
|
||||
@@ -8607,5 +8607,15 @@
|
||||
"toolResult": "",
|
||||
"you": "",
|
||||
"youMessage": ""
|
||||
},
|
||||
"engineBanner": {
|
||||
"body": "The engine for this project is not running, so task automation and live updates may be paused. Start it now to reconnect the dashboard.",
|
||||
"dashboardOnly": "This dashboard cannot start engines from the current process. Run `fn serve` for this project to enable task execution and live automation.",
|
||||
"dashboardOnlyPrefix": "This dashboard cannot start engines from the current process. Run",
|
||||
"dashboardOnlySuffix": "for this project to enable task execution and live automation.",
|
||||
"error": "Start failed: {{message}}",
|
||||
"startCta": "Start engine",
|
||||
"starting": "Starting…",
|
||||
"title": "Project engine is not connected"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8607,5 +8607,15 @@
|
||||
"toolResult": "",
|
||||
"you": "",
|
||||
"youMessage": ""
|
||||
},
|
||||
"engineBanner": {
|
||||
"body": "The engine for this project is not running, so task automation and live updates may be paused. Start it now to reconnect the dashboard.",
|
||||
"dashboardOnly": "This dashboard cannot start engines from the current process. Run `fn serve` for this project to enable task execution and live automation.",
|
||||
"dashboardOnlyPrefix": "This dashboard cannot start engines from the current process. Run",
|
||||
"dashboardOnlySuffix": "for this project to enable task execution and live automation.",
|
||||
"error": "Start failed: {{message}}",
|
||||
"startCta": "Start engine",
|
||||
"starting": "Starting…",
|
||||
"title": "Project engine is not connected"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8607,5 +8607,15 @@
|
||||
"toolResult": "",
|
||||
"you": "",
|
||||
"youMessage": ""
|
||||
},
|
||||
"engineBanner": {
|
||||
"body": "The engine for this project is not running, so task automation and live updates may be paused. Start it now to reconnect the dashboard.",
|
||||
"dashboardOnly": "This dashboard cannot start engines from the current process. Run `fn serve` for this project to enable task execution and live automation.",
|
||||
"dashboardOnlyPrefix": "This dashboard cannot start engines from the current process. Run",
|
||||
"dashboardOnlySuffix": "for this project to enable task execution and live automation.",
|
||||
"error": "Start failed: {{message}}",
|
||||
"startCta": "Start engine",
|
||||
"starting": "Starting…",
|
||||
"title": "Project engine is not connected"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8607,5 +8607,15 @@
|
||||
"toolResult": "",
|
||||
"you": "",
|
||||
"youMessage": ""
|
||||
},
|
||||
"engineBanner": {
|
||||
"body": "The engine for this project is not running, so task automation and live updates may be paused. Start it now to reconnect the dashboard.",
|
||||
"dashboardOnly": "This dashboard cannot start engines from the current process. Run `fn serve` for this project to enable task execution and live automation.",
|
||||
"dashboardOnlyPrefix": "This dashboard cannot start engines from the current process. Run",
|
||||
"dashboardOnlySuffix": "for this project to enable task execution and live automation.",
|
||||
"error": "Start failed: {{message}}",
|
||||
"startCta": "Start engine",
|
||||
"starting": "Starting…",
|
||||
"title": "Project engine is not connected"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8607,5 +8607,15 @@
|
||||
"toolResult": "",
|
||||
"you": "",
|
||||
"youMessage": ""
|
||||
},
|
||||
"engineBanner": {
|
||||
"body": "The engine for this project is not running, so task automation and live updates may be paused. Start it now to reconnect the dashboard.",
|
||||
"dashboardOnly": "This dashboard cannot start engines from the current process. Run `fn serve` for this project to enable task execution and live automation.",
|
||||
"dashboardOnlyPrefix": "This dashboard cannot start engines from the current process. Run",
|
||||
"dashboardOnlySuffix": "for this project to enable task execution and live automation.",
|
||||
"error": "Start failed: {{message}}",
|
||||
"startCta": "Start engine",
|
||||
"starting": "Starting…",
|
||||
"title": "Project engine is not connected"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user