FN-6386: add one-click update installation

Add a dashboard update action that installs available Fusion releases directly from the UI.

- Add an install-update API route backed by npm global install with bin-collision retry handling.
- Add Update now controls, loading/error/success states, and responsive styling to the update banner and settings modal.
- Extend localization, documentation, changeset coverage, and update-check tests for the new install flow.

Files changed:
 .changeset/fn-6386-update-now-button.md            |   5 +
 docs/dashboard-guide.md                            |   4 +
 docs/settings-reference.md                         |   2 +
 packages/dashboard/app/api/legacy.ts               |  13 ++
 .../dashboard/app/components/SettingsModal.css     |  38 +++++-
 .../dashboard/app/components/SettingsModal.tsx     |  96 ++++++++++++--
 .../app/components/UpdateAvailableBanner.css       |  46 +++++++
 .../app/components/UpdateAvailableBanner.tsx       | 103 ++++++++++++---
 .../components/__tests__/SettingsModal.test.tsx    |  79 +++++++++++
 .../__tests__/UpdateAvailableBanner.test.tsx       |  57 +++++++-
 .../components/__tests__/settings-mobile.test.tsx  |  19 +++
 .../src/__tests__/update-check-route.test.ts       |  78 ++++++++++-
 .../dashboard/src/__tests__/update-check.test.ts   |  45 +++++++
 .../src/routes/register-update-check-routes.ts     |  25 +++-
 packages/dashboard/src/update-check.ts             |  93 +++++++++++++
 packages/i18n/locales/en/app.json                  |  14 +-
 packages/i18n/src/resources.d.ts                   | 146 ++++++++++++++++++++-
 17 files changed, 819 insertions(+), 44 deletions(-)

Fusion-Task-Id: FN-6386

Fusion-Task-Lineage: bcccf253-c0f1-493e-b849-b189a65d1479
This commit is contained in:
gsxdsm
2026-06-13 15:01:01 -07:00
parent 338e7286c0
commit e8c2d516af
17 changed files with 819 additions and 44 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Add a one-click dashboard Update now action for installing available Fusion updates.

View File

@@ -4,6 +4,10 @@
The Fusion dashboard is the main control plane for tasks, agents, missions, settings, logs, and repository operations.
## Dashboard Updates
When Fusion detects a newer `@runfusion/fusion` release, the Settings modal footer shows the available version with **Learn more** and **Update now** actions. **Update now** installs the latest global package with npm; after it succeeds, restart Fusion to apply the new version because the already-running dashboard server is unchanged until restart.
## Browser Navigation
The dashboard now handles browser back navigation consistently on desktop and mobile.

View File

@@ -171,6 +171,8 @@ Disable daily update checks globally:
fn settings set updateCheckEnabled false
```
When the dashboard footer reports that a newer `@runfusion/fusion` version is available, **Update now** runs the same global npm install as `fn update` (`npm install -g @runfusion/fusion@latest`) and retries once with `--force` for the legacy `fn`/`fusion` binary-collision case. A successful install updates the global package on disk, but the currently running Fusion server is not hot-swapped; restart Fusion to run the newly installed version.
---
## Workflow Settings

View File

@@ -812,6 +812,19 @@ export function refreshUpdateCheck(projectId?: string): Promise<UpdateCheckRespo
});
}
export interface UpdateInstallResponse {
currentVersion: string;
latestVersion: string | null;
updated: boolean;
error?: string;
}
export function installUpdate(projectId?: string): Promise<UpdateInstallResponse> {
return api<UpdateInstallResponse>(withProjectId("/update-check/install", projectId), {
method: "POST",
});
}
export interface RemoteSettings {
remoteActiveProvider: "tailscale" | "cloudflare" | null;
remoteTailscaleEnabled: boolean;

View File

@@ -184,6 +184,10 @@
row-gap: var(--space-xs);
}
.settings-update-result {
flex: 1 1 100%;
}
.settings-footer-help-btn {
flex-shrink: 0;
}
@@ -229,6 +233,10 @@
}
.settings-update-result {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-xs);
font-size: 0.85rem;
font-weight: 500;
}
@@ -242,7 +250,7 @@
}
.settings-update-result--error {
color: var(--text-muted);
color: var(--color-error);
}
.settings-update-result-link {
@@ -261,6 +269,34 @@
border-radius: var(--radius-sm);
}
.settings-update-now-btn {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
min-height: calc(var(--space-lg) + var(--space-sm));
color: var(--text);
}
.settings-update-now-btn:disabled {
color: var(--color-warning);
}
.settings-update-now-btn svg.spinning {
animation: settings-update-spin 1s linear infinite;
}
.settings-update-install-status {
color: var(--text-muted);
}
.settings-update-install-status--success {
color: var(--color-success);
}
.settings-update-install-status--error {
color: var(--color-error);
}
@keyframes settings-update-spin {
to {
transform: rotate(360deg);

View File

@@ -6,8 +6,8 @@ import {
normalizeMergeAdvanceAutoSyncMode,
} from "@fusion/core";
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset } from "@fusion/core";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, cancelProviderLogin, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotes, fetchGitRemotesDetailed, fetchGitBranches, fetchProjects, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, fetchRemoteStatus, installCloudflared, fetchRemoteQr, fetchRemoteUrl, submitProviderManualCode } from "../api";
import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemote, GitRemoteDetailed, ProjectInfo, RemoteStatus, UpdateCheckResponse, OAuthDeviceCodeInfo } from "../api";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, cancelProviderLogin, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotes, fetchGitRemotesDetailed, fetchGitBranches, fetchProjects, fetchDashboardHealth, checkForUpdates, installUpdate, fetchRemoteSettings, fetchRemoteStatus, installCloudflared, fetchRemoteQr, fetchRemoteUrl, submitProviderManualCode } from "../api";
import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemote, GitRemoteDetailed, ProjectInfo, RemoteStatus, UpdateCheckResponse, UpdateInstallResponse, OAuthDeviceCodeInfo } from "../api";
import { splitSettingsSave } from "./settings/save-split";
import type { SectionSaveHandler } from "./settings/sections/context";
import { AppearanceSection } from "./settings/sections/AppearanceSection";
@@ -686,6 +686,8 @@ export function SettingsModal({
const [appVersion, setAppVersion] = useState<string | null>(null);
const [updateCheckLoading, setUpdateCheckLoading] = useState(false);
const [updateCheckResult, setUpdateCheckResult] = useState<UpdateCheckResponse | null>(null);
const [updateInstallLoading, setUpdateInstallLoading] = useState(false);
const [updateInstallResult, setUpdateInstallResult] = useState<UpdateInstallResponse | null>(null);
const gitHubStarCount = useGitHubStarCount();
const [starClicked, markStarClicked] = useStarClickedFlag();
const [prefixError, setPrefixError] = useState<string | null>(null);
@@ -963,6 +965,7 @@ export function SettingsModal({
const handleCheckForUpdates = useCallback(async () => {
setUpdateCheckLoading(true);
setUpdateInstallResult(null);
try {
const result = await checkForUpdates();
@@ -972,7 +975,7 @@ export function SettingsModal({
addToast(result.error, "error");
}
} catch (error) {
const message = getErrorMessage(error) || "Failed to check for updates";
const message = getErrorMessage(error) || t("settings.general.updateCheckFailed", "Failed to check for updates");
setUpdateCheckResult({
currentVersion: appVersion ?? "unknown",
latestVersion: null,
@@ -983,7 +986,37 @@ export function SettingsModal({
} finally {
setUpdateCheckLoading(false);
}
}, [addToast, appVersion]);
}, [addToast, appVersion, t]);
const handleInstallUpdate = useCallback(async () => {
setUpdateInstallLoading(true);
setUpdateInstallResult(null);
try {
const result = await installUpdate(projectId);
setUpdateInstallResult(result);
if (result.error) {
addToast(result.error, "error");
return;
}
if (result.updated) {
addToast(t("settings.general.updateSuccessToast", "Update installed. Restart Fusion to apply it."), "success");
}
} catch (error) {
const message = getErrorMessage(error) || t("settings.general.updateFailed", "Update failed");
setUpdateInstallResult({
currentVersion: updateCheckResult?.currentVersion ?? appVersion ?? "unknown",
latestVersion: updateCheckResult?.latestVersion ?? null,
updated: false,
error: message,
});
addToast(message, "error");
} finally {
setUpdateInstallLoading(false);
}
}, [addToast, appVersion, projectId, t, updateCheckResult]);
const renderUpdateCheckResultContent = useCallback(() => {
if (!updateCheckResult) {
@@ -995,23 +1028,58 @@ export function SettingsModal({
}
if (updateCheckResult.updateAvailable && updateCheckResult.latestVersion) {
const installSucceeded = updateInstallResult?.updated === true;
const installError = updateInstallResult?.error;
return (
<>
{t("settings.general.updateAvailablePrefix", "v{{version}} available", { version: updateCheckResult.latestVersion })} ·{" "}
<a
href="https://runfusion.ai"
target="_blank"
rel="noreferrer"
className="settings-update-result-link"
>
{t("settings.general.learnMore", "Learn more")}
</a>
<span>
{t("settings.general.updateAvailablePrefix", "v{{version}} available", { version: updateCheckResult.latestVersion })} ·{" "}
<a
href="https://runfusion.ai"
target="_blank"
rel="noreferrer"
className="settings-update-result-link"
>
{t("settings.general.learnMore", "Learn more")}
</a>
</span>
{installSucceeded ? (
<span className="settings-update-install-status settings-update-install-status--success" aria-live="polite">
{t("settings.general.updateSuccess", "Updated to v{{version}} — restart Fusion to apply", {
version: updateInstallResult.latestVersion ?? updateCheckResult.latestVersion,
})}
</span>
) : (
<button
type="button"
className="btn btn-sm settings-update-now-btn"
onClick={() => {
void handleInstallUpdate();
}}
disabled={updateInstallLoading}
>
{updateInstallLoading ? (
<>
<RefreshCw size={12} className="spinning" aria-hidden="true" />
{t("settings.general.updating", "Updating…")}
</>
) : (
t("settings.general.updateNow", "Update now")
)}
</button>
)}
{installError && (
<span className="settings-update-install-status settings-update-install-status--error" aria-live="polite">
{t("settings.general.updateFailedWithMessage", "Update failed: {{message}}", { message: installError })}
</span>
)}
</>
);
}
return t("settings.general.upToDate", "You're up to date ✓");
}, [updateCheckResult]);
}, [handleInstallUpdate, t, updateCheckResult, updateInstallLoading, updateInstallResult]);
// Load auth status when the authentication section is active
const loadAuthStatus = useCallback(async () => {

View File

@@ -13,6 +13,13 @@
color: var(--text);
}
.update-available-banner__content {
display: flex;
flex-direction: column;
gap: var(--space-xs);
min-width: 0;
}
.update-available-banner__text {
margin: 0;
color: var(--text);
@@ -39,6 +46,39 @@
border-radius: var(--radius-sm);
}
.update-available-banner__actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-xs);
}
.update-available-banner__update-btn {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
}
.update-available-banner__update-btn:disabled {
color: var(--color-warning);
}
.update-available-banner__update-btn svg.spinning {
animation: update-available-banner-spin 1s linear infinite;
}
.update-available-banner__install-status {
font-weight: 500;
}
.update-available-banner__install-status--success {
color: var(--color-success);
}
.update-available-banner__install-status--error {
color: var(--color-error);
}
.update-available-banner__dismiss {
background: none;
border: none;
@@ -61,6 +101,12 @@
box-shadow: var(--focus-ring-strong);
}
@keyframes update-available-banner-spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 768px) {
.update-available-banner {
flex-direction: column;

View File

@@ -1,6 +1,10 @@
import "./UpdateAvailableBanner.css";
import { X } from "lucide-react";
import { useState } from "react";
import { RefreshCw, X } from "lucide-react";
import { useTranslation, Trans } from "react-i18next";
import { getErrorMessage } from "@fusion/core";
import { installUpdate } from "../api";
import type { UpdateInstallResponse } from "../api";
interface UpdateAvailableBannerProps {
latestVersion: string;
@@ -10,29 +14,86 @@ interface UpdateAvailableBannerProps {
export function UpdateAvailableBanner({ latestVersion, currentVersion, onDismiss }: UpdateAvailableBannerProps) {
const { t } = useTranslation("app");
const [installLoading, setInstallLoading] = useState(false);
const [installResult, setInstallResult] = useState<UpdateInstallResponse | null>(null);
const handleInstallUpdate = async () => {
setInstallLoading(true);
setInstallResult(null);
try {
setInstallResult(await installUpdate());
} catch (error) {
setInstallResult({
currentVersion,
latestVersion,
updated: false,
error: getErrorMessage(error) || t("updateBanner.updateFailed", "Update failed"),
});
} finally {
setInstallLoading(false);
}
};
const installSucceeded = installResult?.updated === true;
const installError = installResult?.error;
return (
<div className="update-available-banner" role="status" aria-live="polite">
<p className="update-available-banner__text">
<Trans
i18nKey="app:updateBanner.message"
defaults="Update available: v{{latestVersion}} (current: v{{currentVersion}}). Run <code>fn update</code> for an installed CLI, or pull this source checkout."
values={{ latestVersion, currentVersion }}
components={{ code: <code /> }}
/>{" "}
<a
className="update-available-banner__link"
href="https://github.com/Runfusion/Fusion/blob/main/CHANGELOG.md"
target="_blank"
rel="noreferrer"
>
{t("updateBanner.releaseNotes", "Release notes")}
</a>{" "}
·{" "}
<a className="update-available-banner__link" href="https://runfusion.ai" target="_blank" rel="noreferrer">
{t("updateBanner.learnMore", "Learn more")}
</a>
</p>
<div className="update-available-banner__content">
<p className="update-available-banner__text">
<Trans
i18nKey="app:updateBanner.message"
defaults="Update available: v{{latestVersion}} (current: v{{currentVersion}}). Run <code>fn update</code> for an installed CLI, or pull this source checkout."
values={{ latestVersion, currentVersion }}
components={{ code: <code /> }}
/>{" "}
<a
className="update-available-banner__link"
href="https://github.com/Runfusion/Fusion/blob/main/CHANGELOG.md"
target="_blank"
rel="noreferrer"
>
{t("updateBanner.releaseNotes", "Release notes")}
</a>{" "}
·{" "}
<a className="update-available-banner__link" href="https://runfusion.ai" target="_blank" rel="noreferrer">
{t("updateBanner.learnMore", "Learn more")}
</a>
</p>
<div className="update-available-banner__actions">
{installSucceeded ? (
<span className="update-available-banner__install-status update-available-banner__install-status--success" aria-live="polite">
{t("updateBanner.updateSuccess", "Updated to v{{version}} — restart Fusion to apply", {
version: installResult.latestVersion ?? latestVersion,
})}
</span>
) : (
<button
type="button"
className="btn btn-sm update-available-banner__update-btn"
onClick={() => {
void handleInstallUpdate();
}}
disabled={installLoading}
>
{installLoading ? (
<>
<RefreshCw size={12} className="spinning" aria-hidden="true" />
{t("updateBanner.updating", "Updating…")}
</>
) : (
t("updateBanner.updateNow", "Update now")
)}
</button>
)}
{installError && (
<span className="update-available-banner__install-status update-available-banner__install-status--error" aria-live="polite">
{t("updateBanner.updateFailedWithMessage", "Update failed: {{message}}", { message: installError })}
</span>
)}
</div>
</div>
<button
type="button"
className="update-available-banner__dismiss touch-target"

View File

@@ -46,6 +46,7 @@ const mockFetchGitRemotesDetailed = vi.fn();
const mockFetchProjects = vi.fn();
const mockFetchDashboardHealth = vi.fn();
const mockCheckForUpdates = vi.fn();
const mockInstallUpdate = vi.fn();
const mockFetchRemoteSettings = vi.fn();
const mockUpdateRemoteSettings = vi.fn();
const mockFetchRemoteStatus = vi.fn();
@@ -107,6 +108,7 @@ vi.mock("../../api", async (importOriginal) => {
fetchProjects: (...args: unknown[]) => mockFetchProjects(...args),
fetchDashboardHealth: (...args: unknown[]) => mockFetchDashboardHealth(...args),
checkForUpdates: (...args: unknown[]) => mockCheckForUpdates(...args),
installUpdate: (...args: unknown[]) => mockInstallUpdate(...args),
fetchRemoteSettings: (...args: unknown[]) => mockFetchRemoteSettings(...args),
updateRemoteSettings: (...args: unknown[]) => mockUpdateRemoteSettings(...args),
fetchRemoteStatus: (...args: unknown[]) => mockFetchRemoteStatus(...args),
@@ -638,6 +640,7 @@ describe("SettingsModal", () => {
}));
mockFetchDashboardHealth.mockResolvedValue({ status: "ok", version: "1.2.3", uptime: 123 });
mockCheckForUpdates.mockResolvedValue(undefined);
mockInstallUpdate.mockResolvedValue({ currentVersion: "1.2.3", latestVersion: "2.0.0", updated: true });
mockFetchRemoteSettings.mockResolvedValue({
settings: {
remoteActiveProvider: null,
@@ -1803,6 +1806,82 @@ describe("SettingsModal", () => {
expect(await screen.findByText(/v2.0.0 available/i)).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Learn more" })).toHaveAttribute("href", "https://runfusion.ai");
expect(screen.getByRole("button", { name: "Update now" })).toBeInTheDocument();
});
it("hides update-now when update check is up-to-date or errored", async () => {
mockCheckForUpdates.mockResolvedValueOnce({
currentVersion: "1.2.3",
latestVersion: "1.2.3",
updateAvailable: false,
});
const { unmount } = renderModal();
await waitForSettingsModalReady();
await userEvent.click(screen.getByRole("button", { name: "Check for updates" }));
expect(await screen.findByText("You're up to date ✓")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Update now" })).not.toBeInTheDocument();
unmount();
mockCheckForUpdates.mockResolvedValueOnce({
currentVersion: "1.2.3",
latestVersion: null,
updateAvailable: false,
error: "registry unavailable",
});
renderModal();
await waitForSettingsModalReady();
await userEvent.click(screen.getByRole("button", { name: "Check for updates" }));
expect(await screen.findByText("registry unavailable")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Update now" })).not.toBeInTheDocument();
});
it("installs update from the footer and renders restart hint", async () => {
mockCheckForUpdates.mockResolvedValueOnce({
currentVersion: "1.0.0",
latestVersion: "2.0.0",
updateAvailable: true,
});
mockInstallUpdate.mockResolvedValueOnce({ currentVersion: "1.0.0", latestVersion: "2.0.0", updated: true });
renderModal();
await waitForSettingsModalReady();
await userEvent.click(screen.getByRole("button", { name: "Check for updates" }));
await userEvent.click(await screen.findByRole("button", { name: "Update now" }));
await waitFor(() => expect(mockInstallUpdate).toHaveBeenCalledTimes(1));
expect(await screen.findByText("Updated to v2.0.0 — restart Fusion to apply")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Update now" })).not.toBeInTheDocument();
});
it("disables update-now and shows inline errors while installing", async () => {
mockCheckForUpdates.mockResolvedValueOnce({
currentVersion: "1.0.0",
latestVersion: "2.0.0",
updateAvailable: true,
});
let resolveInstall: ((result: { currentVersion: string; latestVersion: string; updated: boolean; error?: string }) => void) | undefined;
mockInstallUpdate.mockReturnValueOnce(new Promise((resolve) => {
resolveInstall = resolve;
}));
renderModal();
await waitForSettingsModalReady();
await userEvent.click(screen.getByRole("button", { name: "Check for updates" }));
const updateNow = await screen.findByRole("button", { name: "Update now" });
fireEvent.click(updateNow);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Updating…" })).toBeDisabled();
});
expect(screen.getByRole("button", { name: "Updating…" }).querySelector(".spinning")).not.toBeNull();
resolveInstall?.({ currentVersion: "1.0.0", latestVersion: "2.0.0", updated: false, error: "install failed" });
expect(await screen.findByText("Update failed: install failed")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Update now" })).not.toBeDisabled();
});
it("disables button while checking", async () => {

View File

@@ -1,9 +1,27 @@
import { describe, it, expect, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useState } from "react";
import { UpdateAvailableBanner } from "../UpdateAvailableBanner";
const mockInstallUpdate = vi.hoisted(() => vi.fn());
vi.mock("../../api", () => ({
installUpdate: (...args: unknown[]) => mockInstallUpdate(...args),
}));
vi.mock("lucide-react", async (importOriginal) => {
const actual = await importOriginal<typeof import("lucide-react")>();
return {
...actual,
RefreshCw: ({ className }: { className?: string }) => <span data-testid="icon-refresh" className={className} />,
};
});
describe("UpdateAvailableBanner", () => {
beforeEach(() => {
mockInstallUpdate.mockReset();
mockInstallUpdate.mockResolvedValue({ currentVersion: "0.6.0", latestVersion: "0.7.0", updated: true });
});
it("renders version information with release notes and learn more links", () => {
render(
<UpdateAvailableBanner latestVersion="0.7.0" currentVersion="0.6.0" onDismiss={vi.fn()} />,
@@ -49,4 +67,39 @@ describe("UpdateAvailableBanner", () => {
fireEvent.click(screen.getByRole("button", { name: "Dismiss update notice" }));
expect(screen.queryByRole("status")).toBeNull();
});
it("disables update-now while installing and then shows restart hint", async () => {
let resolveInstall: ((result: { currentVersion: string; latestVersion: string; updated: boolean }) => void) | undefined;
mockInstallUpdate.mockReturnValueOnce(new Promise((resolve) => {
resolveInstall = resolve;
}));
render(<UpdateAvailableBanner latestVersion="0.7.0" currentVersion="0.6.0" onDismiss={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: "Update now" }));
expect(screen.getByRole("button", { name: "Updating…" })).toBeDisabled();
expect(screen.getByTestId("icon-refresh")).toHaveClass("spinning");
resolveInstall?.({ currentVersion: "0.6.0", latestVersion: "0.7.0", updated: true });
expect(await screen.findByText("Updated to v0.7.0 — restart Fusion to apply")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Update now" })).not.toBeInTheDocument();
});
it("shows install errors inline without removing retry button", async () => {
mockInstallUpdate.mockResolvedValueOnce({
currentVersion: "0.6.0",
latestVersion: "0.7.0",
updated: false,
error: "permission denied",
});
render(<UpdateAvailableBanner latestVersion="0.7.0" currentVersion="0.6.0" onDismiss={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: "Update now" }));
await waitFor(() => expect(mockInstallUpdate).toHaveBeenCalledTimes(1));
expect(await screen.findByText("Update failed: permission denied")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Update now" })).not.toBeDisabled();
});
});

View File

@@ -107,6 +107,8 @@ vi.mock("../../api", () => ({
qmdInstallCommand: "bun install -g @tobilu/qmd",
})),
fetchDashboardHealth: vi.fn(() => Promise.resolve({ status: "ok", version: "1.2.3", uptime: 120 })),
checkForUpdates: vi.fn(() => Promise.resolve({ currentVersion: "1.0.0", latestVersion: "2.0.0", updateAvailable: true })),
installUpdate: vi.fn(() => Promise.resolve({ currentVersion: "1.0.0", latestVersion: "2.0.0", updated: true })),
fetchGlobalSettings: vi.fn(() => Promise.resolve({ ...defaultSettings })),
// SettingsModal renders ProjectDefaultWorkflowField → WorkflowSelector, which loads these on mount.
fetchWorkflows: vi.fn(() => Promise.resolve([])),
@@ -224,6 +226,23 @@ describe("SettingsModal mobile adaptations", () => {
expect(updateButton).toBeTruthy();
});
it("keeps update-now button reachable from the mobile footer", async () => {
mockSettingsViewport(true);
const user = userEvent.setup();
const { container, findByRole, findByText } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
const modalActions = container.querySelector(".modal-actions");
expect(modalActions).toBeTruthy();
await user.click(within(modalActions as HTMLElement).getByRole("button", { name: "Check for updates" }));
const updateNow = await findByRole("button", { name: "Update now" });
expect((modalActions as HTMLElement).contains(updateNow)).toBe(true);
await user.click(updateNow);
expect(await findByText("Updated to v2.0.0 — restart Fusion to apply")).toBeTruthy();
});
it("excludes research sections from mobile picker when researchView is disabled", async () => {
mockSettingsViewport(true);
const user = userEvent.setup();

View File

@@ -6,7 +6,29 @@ import { fileURLToPath } from "node:url";
import { describe, it, expect, vi, afterEach } from "vitest";
import type { TaskStore } from "@fusion/core";
import { createServer } from "../server.js";
import { get as performGet } from "../test-request.js";
import { get as performGet, request as performRequest } from "../test-request.js";
const updateCheckMocks = vi.hoisted(() => ({
performUpdateCheck: vi.fn(),
performUpdateInstall: vi.fn(),
}));
vi.mock("../update-check.js", async () => {
const actual = await vi.importActual<typeof import("../update-check.js")>("../update-check.js");
return {
...actual,
performUpdateCheck: updateCheckMocks.performUpdateCheck,
performUpdateInstall: updateCheckMocks.performUpdateInstall,
};
});
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
return {
...actual,
resolveGlobalDir: () => "/tmp/fusion-update-check-route-test",
};
});
const __dirname = dirname(fileURLToPath(import.meta.url));
const CLI_PACKAGE_VERSION = (() => {
@@ -67,10 +89,64 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
}
afterEach(() => {
updateCheckMocks.performUpdateCheck.mockReset();
updateCheckMocks.performUpdateInstall.mockReset();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("POST /api/update-check/install", () => {
it("installs when a newer version is available", async () => {
updateCheckMocks.performUpdateCheck.mockResolvedValueOnce({
currentVersion: CLI_PACKAGE_VERSION,
latestVersion: "99.0.0",
updateAvailable: true,
lastChecked: 123,
});
updateCheckMocks.performUpdateInstall.mockResolvedValueOnce({
currentVersion: CLI_PACKAGE_VERSION,
latestVersion: "99.0.0",
updated: true,
});
const app = createServer(createMockStore());
const response = await performRequest(app, "POST", "/api/update-check/install");
expect(response.status).toBe(200);
expect(updateCheckMocks.performUpdateCheck).toHaveBeenCalledWith(expect.any(String), CLI_PACKAGE_VERSION, {
force: true,
});
expect(updateCheckMocks.performUpdateInstall).toHaveBeenCalledWith(CLI_PACKAGE_VERSION, "99.0.0", {
fusionDir: expect.any(String),
});
expect(response.body).toEqual({
currentVersion: CLI_PACKAGE_VERSION,
latestVersion: "99.0.0",
updated: true,
});
});
it("returns updated=false without installing when already up to date", async () => {
updateCheckMocks.performUpdateCheck.mockResolvedValueOnce({
currentVersion: CLI_PACKAGE_VERSION,
latestVersion: CLI_PACKAGE_VERSION,
updateAvailable: false,
lastChecked: 123,
});
const app = createServer(createMockStore());
const response = await performRequest(app, "POST", "/api/update-check/install");
expect(response.status).toBe(200);
expect(updateCheckMocks.performUpdateInstall).not.toHaveBeenCalled();
expect(response.body).toEqual({
currentVersion: CLI_PACKAGE_VERSION,
latestVersion: CLI_PACKAGE_VERSION,
updated: false,
});
});
});
describe("GET /api/updates/check", () => {
it("returns updateAvailable=true when npm has a newer version", async () => {
vi.stubGlobal(

View File

@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
clearUpdateCheckCache,
performUpdateCheck,
performUpdateInstall,
readCachedUpdateCheck,
ttlForFrequency,
__resetStartupRefreshFlag,
@@ -161,6 +162,50 @@ describe("update-check", () => {
expect(readCachedUpdateCheck(fusionDir)).toEqual(value);
});
it("performUpdateInstall installs latest and clears the update-check cache", async () => {
const cachePath = join(fusionDir, "update-check.json");
await writeFile(cachePath, JSON.stringify({ ok: true }), "utf-8");
const execFake = vi.fn().mockResolvedValue({ stdout: "", stderr: "" });
const result = await performUpdateInstall("1.0.0", "2.0.0", { exec: execFake, fusionDir });
expect(execFake).toHaveBeenCalledWith("npm install -g @runfusion/fusion@latest", {
timeout: 120_000,
maxBuffer: 10 * 1024 * 1024,
});
expect(result).toEqual({ currentVersion: "1.0.0", latestVersion: "2.0.0", updated: true });
expect(existsSync(cachePath)).toBe(false);
});
it("performUpdateInstall retries once with --force for legacy bin collisions", async () => {
const collision = Object.assign(new Error("EEXIST: file already exists, /usr/local/bin/fn"), {
stderr: "runfusion.ai legacy bin collision",
});
const execFake = vi
.fn()
.mockRejectedValueOnce(collision)
.mockResolvedValueOnce({ stdout: "", stderr: "" });
const result = await performUpdateInstall("1.0.0", "2.0.0", { exec: execFake, fusionDir });
expect(execFake).toHaveBeenCalledTimes(2);
expect(execFake).toHaveBeenNthCalledWith(1, "npm install -g @runfusion/fusion@latest", expect.any(Object));
expect(execFake).toHaveBeenNthCalledWith(2, "npm install --force -g @runfusion/fusion@latest", expect.any(Object));
expect(result).toEqual({ currentVersion: "1.0.0", latestVersion: "2.0.0", updated: true });
});
it("performUpdateInstall returns an error result for non-collision install failures", async () => {
const execFake = vi.fn().mockRejectedValue(Object.assign(new Error("npm unavailable"), { stderr: "registry down" }));
await expect(performUpdateInstall("1.0.0", "2.0.0", { exec: execFake, fusionDir })).resolves.toEqual({
currentVersion: "1.0.0",
latestVersion: "2.0.0",
updated: false,
error: "registry down",
});
expect(execFake).toHaveBeenCalledTimes(1);
});
describe("frequency", () => {
beforeEach(() => {
__resetStartupRefreshFlag();

View File

@@ -1,5 +1,5 @@
import { resolveGlobalDir } from "@fusion/core";
import { clearUpdateCheckCache, performUpdateCheck } from "../update-check.js";
import { clearUpdateCheckCache, performUpdateCheck, performUpdateInstall } from "../update-check.js";
import { getCliPackageVersion } from "../cli-package-version.js";
import type { ApiRouteRegistrar } from "./types.js";
@@ -44,4 +44,27 @@ export const registerUpdateCheckRoutes: ApiRouteRegistrar = (ctx) => {
rethrowAsApiError(error, "Failed to refresh update check");
}
});
router.post("/update-check/install", async (_req, res) => {
try {
const fusionDir = resolveGlobalDir();
const updateCheck = await performUpdateCheck(fusionDir, cliPackageVersion, {
force: true,
});
if (!updateCheck.updateAvailable || !updateCheck.latestVersion) {
res.json({
currentVersion: updateCheck.currentVersion,
latestVersion: updateCheck.latestVersion,
updated: false,
});
return;
}
const result = await performUpdateInstall(updateCheck.currentVersion, updateCheck.latestVersion, { fusionDir });
res.json(result);
} catch (error) {
rethrowAsApiError(error, "Failed to install update");
}
});
};

View File

@@ -1,11 +1,19 @@
import { exec } from "node:child_process";
import { readFileSync } from "node:fs";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
import { resolveGlobalDir } from "@fusion/core";
const CACHE_FILENAME = "update-check.json";
const REGISTRY_URL = "https://registry.npmjs.org/@runfusion%2Ffusion";
const INSTALL_COMMAND = "npm install -g @runfusion/fusion@latest";
const FORCE_INSTALL_COMMAND = "npm install --force -g @runfusion/fusion@latest";
const INSTALL_TIMEOUT_MS = 120_000;
const INSTALL_MAX_BUFFER = 10 * 1024 * 1024;
const DAY_MS = 24 * 60 * 60 * 1000;
const execAsync = promisify(exec);
/** Allowed update-check cadences from GlobalSettings. */
export type UpdateCheckFrequency = "manual" | "on-startup" | "daily" | "weekly";
@@ -18,6 +26,20 @@ export type UpdateCheckResult = {
error?: string;
};
export type UpdateInstallResult = {
currentVersion: string;
latestVersion: string | null;
updated: boolean;
error?: string;
};
type ExecInstall = (
command: string,
options: { timeout: number; maxBuffer: number },
) => Promise<{ stdout: string; stderr: string }>;
type InstallError = Error & { stdout?: string; stderr?: string };
/**
* Cache TTL in ms for the given frequency. Frequencies that don't expire by
* elapsed time (`manual`, `on-startup`) return Infinity — those modes rely on
@@ -63,6 +85,32 @@ function isRemoteNewer(remoteVersion: string, currentVersion: string): boolean {
return false;
}
function isBinCollisionInstallError(error: unknown): boolean {
const installError = error as InstallError;
const message = [installError?.message, installError?.stderr, installError?.stdout]
.filter((part): part is string => typeof part === "string" && part.length > 0)
.join("\n");
const hasBinHint = /\/(fn|fusion)\b|runfusion\.ai/i.test(message);
if (!hasBinHint) return false;
return /EEXIST|ENOENT|File exists/i.test(message);
}
function getInstallErrorMessage(error: unknown): string {
const installError = error as InstallError;
const stderr = typeof installError?.stderr === "string" ? installError.stderr.trim() : "";
if (stderr.length > 0) return stderr;
return error instanceof Error ? error.message : String(error);
}
function getInstallOptions(): { timeout: number; maxBuffer: number } {
return {
timeout: INSTALL_TIMEOUT_MS,
maxBuffer: INSTALL_MAX_BUFFER,
};
}
function isValidResult(value: unknown): value is UpdateCheckResult {
if (!value || typeof value !== "object") return false;
const candidate = value as Record<string, unknown>;
@@ -103,6 +151,51 @@ export async function clearUpdateCheckCache(fusionDir: string): Promise<void> {
await rm(getCachePath(fusionDir), { force: true });
}
export async function performUpdateInstall(
currentVersion: string,
latestVersion: string | null,
options: { exec?: ExecInstall; fusionDir?: string } = {},
): Promise<UpdateInstallResult> {
const runExec = options.exec ?? execAsync;
const fusionDir = options.fusionDir ?? resolveGlobalDir();
try {
await runExec(INSTALL_COMMAND, getInstallOptions());
await clearUpdateCheckCache(fusionDir);
return {
currentVersion,
latestVersion,
updated: true,
};
} catch (error) {
if (!isBinCollisionInstallError(error)) {
return {
currentVersion,
latestVersion,
updated: false,
error: getInstallErrorMessage(error),
};
}
try {
await runExec(FORCE_INSTALL_COMMAND, getInstallOptions());
await clearUpdateCheckCache(fusionDir);
return {
currentVersion,
latestVersion,
updated: true,
};
} catch (forceError) {
return {
currentVersion,
latestVersion,
updated: false,
error: getInstallErrorMessage(forceError),
};
}
}
}
export async function performUpdateCheck(
fusionDir: string,
currentVersion: string,

View File

@@ -4981,6 +4981,13 @@
"learnMore": "Learn more",
"settingsSaved": "Settings saved",
"updateAvailablePrefix": "v{{version}} available",
"updateCheckFailed": "Failed to check for updates",
"updateFailed": "Update failed",
"updateFailedWithMessage": "Update failed: {{message}}",
"updateNow": "Update now",
"updateSuccess": "Updated to v{{version}} — restart Fusion to apply",
"updateSuccessToast": "Update installed. Restart Fusion to apply it.",
"updating": "Updating…",
"upToDate": "You're up to date ✓"
},
"header": {
@@ -6622,7 +6629,12 @@
"dismissLabel": "Dismiss update notice",
"learnMore": "Learn more",
"message": "Update available: v{{latestVersion}} (current: v{{currentVersion}}). Run fn update for an installed CLI, or pull this source checkout.",
"releaseNotes": "Release notes"
"releaseNotes": "Release notes",
"updateFailed": "Update failed",
"updateFailedWithMessage": "Update failed: {{message}}",
"updateNow": "Update now",
"updateSuccess": "Updated to v{{version}} — restart Fusion to apply",
"updating": "Updating…"
},
"usage": {
"configureAuthHint": "Configure authentication in Settings to see usage data.",

View File

@@ -566,6 +566,7 @@ export default interface Resources {
"last24h": "Last 24h",
"last7d": "Last 7 days",
"lastHeartbeat": "Last heartbeat",
"lastHeartbeatAt": "Last: {{time}}",
"latestRunLabel": "Latest run",
"layoutAuto": "Auto",
"layoutAutoAria": "Automatic layout",
@@ -658,6 +659,7 @@ export default interface Resources {
"next": "Next",
"nextExpected": "Next expected",
"nextHeartbeat": "Next heartbeat in {{elapsed}}",
"nextHeartbeatAt": "Next: {{time}}",
"noActiveAssignment": "No active assignment",
"noActiveEligible": "No active agents eligible to pause",
"noActivityYet": "No activity yet",
@@ -1133,6 +1135,7 @@ export default interface Resources {
"needsInput": "needs input"
},
"typeLabel": {
"cliAgent": "CLI Agent",
"milestoneInterview": "Milestone Interview",
"missionInterview": "Mission Interview",
"planning": "Planning",
@@ -1346,6 +1349,27 @@ export default interface Resources {
"stateVersionMismatch": "Version mismatch",
"succeededDuration": "Install succeeded in {{duration}}s"
},
"cliTerminal": {
"adapterSettings": "Adapter settings",
"advance": "Advance",
"advancePrompt": "This session looks idle — advance to review?",
"mobileInputPlaceholder": "Type to send to the session…",
"mobileKeyArrowDown": "Cursor down",
"mobileKeyArrowLeft": "Cursor left",
"mobileKeyArrowRight": "Cursor right",
"mobileKeyArrowUp": "Cursor up",
"mobileKeyCtrl": "Sticky Ctrl modifier",
"mobileKeyCtrlC": "Send Ctrl-C",
"mobileKeyEsc": "Send Escape",
"mobileKeyTab": "Send Tab",
"mobileSend": "Send",
"notYet": "Not yet",
"postureBaseline": "Baseline",
"postureResolved": "Resolved posture",
"readOnly": "Read-only",
"replayEnded": "Session ended",
"replayIdle": "Session idle"
},
"column": {
"actionsAriaLabel": "{{columnLabel}} column actions",
"actionsTitle": "Column actions",
@@ -1631,12 +1655,19 @@ export default interface Resources {
"dirPicker": {
"ariaLabel": "Directory browser",
"browse": "Browse",
"cancel": "Cancel",
"closeBrowser": "Close directory browser",
"createFolder": "New folder",
"createFolderAria": "Create new folder",
"createFolderConfirm": "Create",
"createFolderError": "Folder name cannot contain path separators or '..'",
"createFolderTitle": "Create folder",
"defaultPlaceholder": "/path/to/your/project",
"hideHidden": "Hide hidden",
"hideHiddenAria": "Hide hidden directories",
"hideHiddenTitle": "Hide hidden",
"loading": "Loading…",
"newFolderPlaceholder": "Folder name",
"noSubdirs": "No subdirectories",
"openBrowser": "Browse directories",
"parentDir": "Go to parent directory",
@@ -4293,8 +4324,10 @@ export default interface Resources {
"saving": "Saving..."
},
"addCustom": "Add Custom Provider",
"apiKeyKeepPlaceholder": "Leave blank to keep current key",
"apiKeyLabel": "API key",
"apiTypeAnthropic": "Anthropic-compatible",
"apiTypeGoogle": "Google Generative AI",
"apiTypeInvalid": "API type is invalid.",
"apiTypeLabel": "API type",
"apiTypeOpenAi": "OpenAI-compatible",
@@ -4878,6 +4911,16 @@ export default interface Resources {
"valueLabel": "Value"
},
"sessionBanner": {
"cli": {
"advance": "Advance",
"authFailed": "CLI authentication failed",
"cancelTask": "Cancel task",
"reauthenticate": "Re-authenticate",
"relaunch": "Relaunch fresh",
"resumeExhausted": "Couldn't resume the session",
"retry": "Retry",
"userExited": "Agent exited before completing"
},
"dismissAll": "Dismiss all",
"dismissItem": "Dismiss {{title}}",
"failed": "Failed",
@@ -4893,7 +4936,10 @@ export default interface Resources {
"headerErrorSingular_other": "",
"regionLabel": "AI sessions needing input or failed",
"resume": "Resume",
"retry": "Retry"
"retry": "Retry",
"typeLabel": {
"cliAgent": "CLI Agent"
}
},
"settings": {
"actions": {
@@ -4948,6 +4994,34 @@ export default interface Resources {
"backupNow": "Backup Now",
"creating": "Creating…"
},
"cliAgents": {
"adapterLabel": "Adapter",
"approveFailed": "Failed to record autonomy approval",
"approvedNote": "Elevated autonomy is approved for this project.",
"autonomy": {
"default": "Default (request approvals)",
"elevated": "Elevated (bypass approvals)"
},
"autonomyHelp": "Elevated autonomy requires a per-project approval before the agent can launch.",
"autonomyLabel": "Autonomy mode",
"commandHelp": "Path or name of the binary to launch. A non-default value is treated as privileged and requires autonomy approval.",
"commandLabel": "Command override",
"description": "Per-adapter launch configuration for CLI coding agents driven in engine-owned terminals.",
"elevatedConfirmAction": "Approve elevated autonomy",
"elevatedConfirmBody": "Elevated autonomy lets this CLI agent bypass per-step approvals (e.g. --dangerously-skip-permissions). It can modify files and run commands without pausing. Approve only if you trust this adapter for this project.",
"elevatedConfirmTitle": "Approve elevated autonomy?",
"envHelp": "Comma-separated variable NAMES forwarded from the parent process. Service credentials (FUSION_*) are always excluded.",
"envLabel": "Environment variable additions",
"extraArgsHelp": "Appended after the adapter's computed arguments (space-separated). Bypass flags here are detected and gated.",
"extraArgsLabel": "Extra arguments",
"heading": "CLI Agents",
"saveFailed": "Failed to save CLI agent settings",
"tier": {
"generic": "generic",
"hybrid": "hybrid",
"native": "native"
}
},
"closeModal": "Close conflict modal",
"conflictModalTitle": "Resolve Settings Conflicts",
"footer": {
@@ -4958,7 +5032,14 @@ export default interface Resources {
"learnMore": "Learn more",
"settingsSaved": "Settings saved",
"upToDate": "You're up to date ✓",
"updateAvailablePrefix": "v{{version}} available"
"updateAvailablePrefix": "v{{version}} available",
"updateCheckFailed": "Failed to check for updates",
"updateFailed": "Update failed",
"updateFailedWithMessage": "Update failed: {{message}}",
"updateNow": "Update now",
"updateSuccess": "Updated to v{{version}} — restart Fusion to apply",
"updateSuccessToast": "Update installed. Restart Fusion to apply it.",
"updating": "Updating…"
},
"header": {
"discord": "Discord"
@@ -5002,11 +5083,19 @@ export default interface Resources {
"presetNameRequired": "Preset name is required",
"savePreset": "Save preset"
},
"movedStub": {
"modelLanes": "Per-phase model lanes (execution, planning, reviewer, their fallbacks, and the title summarizer) now live on the workflow.",
"openWorkflowSettings": "Open workflow settings",
"reviewVerification": "Review, verification auto-fix, and scope-enforcement settings now live on the workflow.",
"stepExecution": "Step execution settings (run steps in new sessions, max parallel steps) now live on the workflow.",
"summarizerModelInline": "The model used for summarization now lives on the workflow (title summarizer lane). Open workflow settings to choose it."
},
"nav": {
"aria": {
"global": "Global setting",
"project": "Project setting"
},
"cliAgents": "CLI Agents",
"tooltip": {
"global": "Shared across all projects",
"project": "Specific to this project"
@@ -6039,6 +6128,7 @@ export default interface Resources {
},
"tabs": {
"changes": "Changes",
"chat": "Chat",
"comments": "Comments",
"definition": "Definition",
"documents": "Documents",
@@ -6048,8 +6138,12 @@ export default interface Resources {
"review": "Review",
"routing": "Routing",
"stats": "Stats",
"terminal": "Terminal",
"workflow": "Workflow"
},
"terminal": {
"loading": "Loading terminal…"
},
"timedDuration": "Timed duration",
"timestamps": {
"ariaLabel": "Task timestamps",
@@ -6295,6 +6389,10 @@ export default interface Resources {
"branchProgressTitle": "Parallel branches in progress",
"cancelMove": "Cancel Move",
"clearSelection": "Clear selection",
"cliNeedsAttention": "Needs attention",
"cliNeedsAttentionTitle": "The CLI agent needs your attention",
"cliWaitingOnInput": "Waiting on input",
"cliWaitingOnInputTitle": "The CLI agent is waiting for your input",
"closeIssue": "Close Issue",
"collapse": "Collapse",
"createFailed": "Failed to create task",
@@ -6554,7 +6652,12 @@ export default interface Resources {
"dismissLabel": "Dismiss update notice",
"learnMore": "Learn more",
"message": "Update available: v{{latestVersion}} (current: v{{currentVersion}}). Run fn update for an installed CLI, or pull this source checkout.",
"releaseNotes": "Release notes"
"releaseNotes": "Release notes",
"updateFailed": "Update failed",
"updateFailedWithMessage": "Update failed: {{message}}",
"updateNow": "Update now",
"updateSuccess": "Updated to v{{version}} — restart Fusion to apply",
"updating": "Updating…"
},
"usage": {
"configureAuthHint": "Configure authentication in Settings to see usage data.",
@@ -6713,13 +6816,29 @@ export default interface Resources {
},
"workflowColumns": {
"add": "Add column",
"agent": "Column agent",
"agentBadgeDefer": "Column agent (defer)",
"agentBadgeOverride": "Column agent (override)",
"agentFlagHint": "Enable both experimentalFeatures.workflowColumns and experimentalFeatures.workflowGraphExecutor to staff columns with agents",
"agentLabel": "Column agent",
"agentMode": "Agent mode",
"agentModeDefer": "Defer",
"agentModeDeferHint": "Column agent applies only when the work carries no agent/model settings of its own",
"agentModeOverride": "Override",
"agentModeOverrideHint": "Column agent supersedes node- and task-level agent/model settings",
"agentNone": "(none)",
"agentNotFound": "Agent not found — {{id}}",
"agentsLoadFailed": "Failed to load agents",
"compositionBlocked": "Resolve trait conflicts on highlighted columns before saving",
"confirmPolicyEscalation": "Bind it anyway? The column agent will run with broader permissions than this project's default.",
"empty": "No columns yet. Add a column to place nodes into board lanes.",
"escalationDeclined": "Save cancelled — column agent binding not confirmed",
"moveDown": "Move column down",
"moveUp": "Move column up",
"nameLabel": "Column name",
"newColumnName": "New column",
"nodeUnplaced": "Not placed in a column",
"overriddenByColumnAgent": "Overridden by column agent {{name}} — this node's executor settings are superseded.",
"readOnlyHint": "Built-in workflows are read-only — duplicate to edit",
"remove": "Remove column",
"title": "Columns",
@@ -6728,6 +6847,27 @@ export default interface Resources {
"unplacedCount_one": "{{count}} nodes not placed in a column",
"unplacedCount_other": "{{count}} nodes not placed in a column"
},
"workflowEditor": {
"cliAgent": {
"adapterLabel": "CLI adapter",
"adapterNote": "Drives a CLI coding agent in an engine-owned terminal for this step.",
"adapterPlaceholder": "— select adapter —",
"autonomyLabel": "Elevated autonomy (bypass approvals)",
"autonomyNote": "Elevated autonomy requires a per-project approval before the agent can launch. Until approved, launches with elevated posture fail.",
"executorOption": "CLI agent",
"notify": {
"banner": "In-app banner",
"bannerNotify": "Banner + push notification"
},
"notifyLabel": "Waiting-on-input notification",
"notifyNote": "How you are alerted when the agent pauses waiting for input on this step.",
"tier": {
"generic": "generic",
"hybrid": "hybrid",
"native": "native"
}
}
},
"workflowFields": {
"add": "Add field",
"addOption": "Add option",