diff --git a/src/browser/App.stories.tsx b/src/browser/App.stories.tsx index ff4c30db0e..18e42e68db 100644 --- a/src/browser/App.stories.tsx +++ b/src/browser/App.stories.tsx @@ -377,6 +377,83 @@ export const ManyWorkspaces: Story = { }, }; +/** + * Story demonstrating the incompatible workspace error view. + * + * When a user downgrades to an older version of mux that doesn't support + * a workspace's runtime configuration, the workspace shows an error message + * instead of crashing. This ensures graceful degradation. + */ +export const IncompatibleWorkspace: Story = { + render: () => { + const AppWithIncompatibleWorkspace = () => { + const initialized = useRef(false); + + if (!initialized.current) { + const workspaceId = "incompatible-ws"; + + const workspaces: FrontendWorkspaceMetadata[] = [ + { + id: "my-app-main", + name: "main", + projectPath: "/home/user/projects/my-app", + projectName: "my-app", + namedWorkspacePath: "/home/user/.mux/src/my-app/main", + runtimeConfig: DEFAULT_RUNTIME_CONFIG, + }, + { + id: workspaceId, + name: "incompatible", + projectPath: "/home/user/projects/my-app", + projectName: "my-app", + namedWorkspacePath: "/home/user/.mux/src/my-app/incompatible", + runtimeConfig: DEFAULT_RUNTIME_CONFIG, + // This field is set when a workspace has an incompatible runtime config + incompatibleRuntime: + "This workspace was created with a newer version of mux.\nPlease upgrade mux to use this workspace.", + }, + ]; + + setupMockAPI({ + projects: new Map([ + [ + "/home/user/projects/my-app", + { + workspaces: [ + { path: "/home/user/.mux/src/my-app/main", id: "my-app-main", name: "main" }, + { + path: "/home/user/.mux/src/my-app/incompatible", + id: workspaceId, + name: "incompatible", + }, + ], + }, + ], + ]), + workspaces, + }); + + // Set initial workspace selection to the incompatible workspace + localStorage.setItem( + "selectedWorkspace", + JSON.stringify({ + workspaceId: workspaceId, + projectPath: "/home/user/projects/my-app", + projectName: "my-app", + namedWorkspacePath: "/home/user/.mux/src/my-app/incompatible", + }) + ); + + initialized.current = true; + } + + return ; + }; + + return ; + }, +}; + /** * Story demonstrating all possible UI indicators in the project sidebar. * diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 8c34d359d1..b3833917d9 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -574,6 +574,9 @@ function AppInner() { runtimeConfig={ workspaceMetadata.get(selectedWorkspace.workspaceId)?.runtimeConfig } + incompatibleRuntime={ + workspaceMetadata.get(selectedWorkspace.workspaceId)?.incompatibleRuntime + } /> ) : creationProjectPath ? ( diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index 9ebff9d198..ffe3bb3917 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -49,6 +49,8 @@ interface AIViewProps { namedWorkspacePath: string; // User-friendly path for display and terminal runtimeConfig?: RuntimeConfig; className?: string; + /** If set, workspace is incompatible (from newer mux version) and this error should be displayed */ + incompatibleRuntime?: string; } const AIViewInner: React.FC = ({ @@ -607,8 +609,37 @@ const AIViewInner: React.FC = ({ ); }; +/** + * Incompatible workspace error display. + * Shown when a workspace was created with a newer version of mux. + */ +const IncompatibleWorkspaceView: React.FC<{ message: string; className?: string }> = ({ + message, + className, +}) => ( + + + ⚠️ + + Incompatible Workspace + + {message} + + You can delete this workspace and create a new one, or upgrade mux to use it. + + + +); + // Wrapper component that provides the mode and thinking contexts export const AIView: React.FC = (props) => { + // Early return for incompatible workspaces - no hooks called in this path + if (props.incompatibleRuntime) { + return ( + + ); + } + return ( diff --git a/src/browser/components/ChatInputToasts.test.ts b/src/browser/components/ChatInputToasts.test.ts index 229b4e1b2f..9011c18d12 100644 --- a/src/browser/components/ChatInputToasts.test.ts +++ b/src/browser/components/ChatInputToasts.test.ts @@ -69,5 +69,18 @@ describe("ChatInputToasts", () => { expect(toast.title).toBe("Message Send Failed"); expect(toast.message).toContain("unexpected error"); }); + + test("should create toast for incompatible_workspace error", () => { + const error: SendMessageError = { + type: "incompatible_workspace", + message: "This workspace uses a runtime configuration from a newer version of mux.", + }; + + const toast = createErrorToast(error); + + expect(toast.type).toBe("error"); + expect(toast.title).toBe("Incompatible Workspace"); + expect(toast.message).toContain("newer version"); + }); }); }); diff --git a/src/browser/components/ChatInputToasts.tsx b/src/browser/components/ChatInputToasts.tsx index 3f8d0a6bfb..3276f3e610 100644 --- a/src/browser/components/ChatInputToasts.tsx +++ b/src/browser/components/ChatInputToasts.tsx @@ -195,6 +195,21 @@ export const createErrorToast = (error: SendMessageErrorType): Toast => { }; } + case "incompatible_workspace": { + return { + id: Date.now().toString(), + type: "error", + title: "Incompatible Workspace", + message: error.message, + solution: ( + <> + Solution: + Upgrade mux to use this workspace, or delete it and create a new one. + > + ), + }; + } + case "unknown": default: { const formatted = formatSendMessageError(error); diff --git a/src/browser/utils/messages/retryEligibility.test.ts b/src/browser/utils/messages/retryEligibility.test.ts index 403f1488a2..ef1faf934b 100644 --- a/src/browser/utils/messages/retryEligibility.test.ts +++ b/src/browser/utils/messages/retryEligibility.test.ts @@ -594,4 +594,12 @@ describe("isNonRetryableSendError", () => { }; expect(isNonRetryableSendError(error)).toBe(false); }); + + it("returns true for incompatible_workspace error", () => { + const error: SendMessageError = { + type: "incompatible_workspace", + message: "This workspace uses a runtime configuration from a newer version of mux.", + }; + expect(isNonRetryableSendError(error)).toBe(true); + }); }); diff --git a/src/browser/utils/messages/retryEligibility.ts b/src/browser/utils/messages/retryEligibility.ts index eed8ec69f8..565c6f9236 100644 --- a/src/browser/utils/messages/retryEligibility.ts +++ b/src/browser/utils/messages/retryEligibility.ts @@ -52,6 +52,7 @@ export function isNonRetryableSendError(error: SendMessageError): boolean { case "api_key_not_found": // Missing API key - user must configure case "provider_not_supported": // Unsupported provider - user must switch case "invalid_model_string": // Bad model format - user must fix + case "incompatible_workspace": // Workspace from newer mux version - user must upgrade return true; case "unknown": return false; // Unknown errors might be transient diff --git a/src/common/types/errors.ts b/src/common/types/errors.ts index 1231ec4dca..2ec56cb9c1 100644 --- a/src/common/types/errors.ts +++ b/src/common/types/errors.ts @@ -12,6 +12,7 @@ export type SendMessageError = | { type: "api_key_not_found"; provider: string } | { type: "provider_not_supported"; provider: string } | { type: "invalid_model_string"; message: string } + | { type: "incompatible_workspace"; message: string } | { type: "unknown"; raw: string }; /** diff --git a/src/common/types/workspace.ts b/src/common/types/workspace.ts index 465cd38d73..ca32f15051 100644 --- a/src/common/types/workspace.ts +++ b/src/common/types/workspace.ts @@ -74,6 +74,13 @@ export interface GitStatus { export interface FrontendWorkspaceMetadata extends WorkspaceMetadata { /** Worktree path (uses workspace name as directory) */ namedWorkspacePath: string; + + /** + * If set, this workspace has an incompatible runtime configuration + * (e.g., from a newer version of mux). The workspace should be displayed + * but interactions should show this error message. + */ + incompatibleRuntime?: string; } export interface WorkspaceActivitySnapshot { diff --git a/src/common/utils/errors/formatSendError.ts b/src/common/utils/errors/formatSendError.ts index e287ab3cd1..befb24bdd5 100644 --- a/src/common/utils/errors/formatSendError.ts +++ b/src/common/utils/errors/formatSendError.ts @@ -32,6 +32,11 @@ export function formatSendMessageError(error: SendMessageError): FormattedError message: error.message, }; + case "incompatible_workspace": + return { + message: error.message, + }; + case "unknown": return { message: error.raw || "An unexpected error occurred", diff --git a/src/common/utils/runtimeCompatibility.ts b/src/common/utils/runtimeCompatibility.ts new file mode 100644 index 0000000000..1ea3bcf4f5 --- /dev/null +++ b/src/common/utils/runtimeCompatibility.ts @@ -0,0 +1,31 @@ +/** + * Runtime configuration compatibility checks. + * + * This module is intentionally in common/ to avoid circular dependencies + * with runtime implementations (LocalRuntime, SSHRuntime, etc.). + */ + +import type { RuntimeConfig } from "@/common/types/runtime"; + +/** + * Check if a runtime config is from a newer version and incompatible. + * + * This handles downgrade compatibility: if a user upgrades to a version + * with new runtime types (e.g., "local" without srcBaseDir for project-dir mode), + * then downgrades, those workspaces should show a clear error rather than crashing. + */ +export function isIncompatibleRuntimeConfig(config: RuntimeConfig | undefined): boolean { + if (!config) { + return false; + } + // Future versions may add "local" without srcBaseDir (project-dir mode) + // or new types like "worktree". Detect these as incompatible. + if (config.type === "local" && !("srcBaseDir" in config && config.srcBaseDir)) { + return true; + } + // Unknown types from future versions + if (config.type !== "local" && config.type !== "ssh") { + return true; + } + return false; +} diff --git a/src/node/config.ts b/src/node/config.ts index be51f3bdfd..62eaa7799d 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -7,6 +7,7 @@ import type { WorkspaceMetadata, FrontendWorkspaceMetadata } from "@/common/type import type { Secret, SecretsConfig } from "@/common/types/secrets"; import type { Workspace, ProjectConfig, ProjectsConfig } from "@/common/types/project"; import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; +import { isIncompatibleRuntimeConfig } from "@/common/utils/runtimeCompatibility"; import { getMuxHome } from "@/common/constants/paths"; import { PlatformPaths } from "@/common/utils/paths"; @@ -141,10 +142,19 @@ export class Config { workspacePath: string, _projectPath: string ): FrontendWorkspaceMetadata { - return { + const result: FrontendWorkspaceMetadata = { ...metadata, namedWorkspacePath: workspacePath, }; + + // Check for incompatible runtime configs (from newer mux versions) + if (isIncompatibleRuntimeConfig(metadata.runtimeConfig)) { + result.incompatibleRuntime = + "This workspace was created with a newer version of mux. " + + "Please upgrade mux to use this workspace."; + } + + return result; } /** diff --git a/src/node/runtime/runtimeFactory.test.ts b/src/node/runtime/runtimeFactory.test.ts new file mode 100644 index 0000000000..bb89add4e3 --- /dev/null +++ b/src/node/runtime/runtimeFactory.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "bun:test"; +import { isIncompatibleRuntimeConfig } from "@/common/utils/runtimeCompatibility"; +import { createRuntime, IncompatibleRuntimeError } from "./runtimeFactory"; +import type { RuntimeConfig } from "@/common/types/runtime"; + +describe("isIncompatibleRuntimeConfig", () => { + it("returns false for undefined config", () => { + expect(isIncompatibleRuntimeConfig(undefined)).toBe(false); + }); + + it("returns false for valid local config with srcBaseDir", () => { + const config: RuntimeConfig = { + type: "local", + srcBaseDir: "~/.mux/src", + }; + expect(isIncompatibleRuntimeConfig(config)).toBe(false); + }); + + it("returns false for valid SSH config", () => { + const config: RuntimeConfig = { + type: "ssh", + host: "example.com", + srcBaseDir: "/home/user/mux", + }; + expect(isIncompatibleRuntimeConfig(config)).toBe(false); + }); + + it("returns true for local config without srcBaseDir (future project-dir mode)", () => { + // Simulate a config from a future version that has type: "local" without srcBaseDir + // This bypasses TypeScript checks to simulate runtime data from newer versions + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const config = { type: "local" } as RuntimeConfig; + expect(isIncompatibleRuntimeConfig(config)).toBe(true); + }); + + it("returns true for local config with empty srcBaseDir", () => { + // Simulate a malformed config - empty srcBaseDir shouldn't be valid + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const config = { type: "local", srcBaseDir: "" } as RuntimeConfig; + expect(isIncompatibleRuntimeConfig(config)).toBe(true); + }); + + it("returns true for unknown runtime type (future types like worktree)", () => { + // Simulate a config from a future version with new type + + const config = { type: "worktree", srcBaseDir: "~/.mux/src" } as unknown as RuntimeConfig; + expect(isIncompatibleRuntimeConfig(config)).toBe(true); + }); +}); + +describe("createRuntime", () => { + it("creates LocalRuntime for valid local config", () => { + const config: RuntimeConfig = { + type: "local", + srcBaseDir: "/tmp/test-src", + }; + const runtime = createRuntime(config); + expect(runtime).toBeDefined(); + }); + + it("throws IncompatibleRuntimeError for local config without srcBaseDir", () => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const config = { type: "local" } as RuntimeConfig; + expect(() => createRuntime(config)).toThrow(IncompatibleRuntimeError); + expect(() => createRuntime(config)).toThrow(/newer version of mux/); + }); + + it("throws IncompatibleRuntimeError for unknown runtime type", () => { + const config = { type: "worktree", srcBaseDir: "~/.mux/src" } as unknown as RuntimeConfig; + expect(() => createRuntime(config)).toThrow(IncompatibleRuntimeError); + expect(() => createRuntime(config)).toThrow(/newer version of mux/); + }); +}); diff --git a/src/node/runtime/runtimeFactory.ts b/src/node/runtime/runtimeFactory.ts index 86995490de..1686d85fd3 100644 --- a/src/node/runtime/runtimeFactory.ts +++ b/src/node/runtime/runtimeFactory.ts @@ -2,11 +2,34 @@ import type { Runtime } from "./Runtime"; import { LocalRuntime } from "./LocalRuntime"; import { SSHRuntime } from "./SSHRuntime"; import type { RuntimeConfig } from "@/common/types/runtime"; +import { isIncompatibleRuntimeConfig } from "@/common/utils/runtimeCompatibility"; + +// Re-export for backward compatibility with existing imports +export { isIncompatibleRuntimeConfig }; + +/** + * Error thrown when a workspace has an incompatible runtime configuration, + * typically from a newer version of mux that added new runtime types. + */ +export class IncompatibleRuntimeError extends Error { + constructor(message: string) { + super(message); + this.name = "IncompatibleRuntimeError"; + } +} /** * Create a Runtime instance based on the configuration */ export function createRuntime(config: RuntimeConfig): Runtime { + // Check for incompatible configs from newer versions + if (isIncompatibleRuntimeConfig(config)) { + throw new IncompatibleRuntimeError( + `This workspace uses a runtime configuration from a newer version of mux. ` + + `Please upgrade mux to use this workspace.` + ); + } + switch (config.type) { case "local": return new LocalRuntime(config.srcBaseDir); diff --git a/src/node/services/ipcMain.ts b/src/node/services/ipcMain.ts index a24af7c4bf..a54671b279 100644 --- a/src/node/services/ipcMain.ts +++ b/src/node/services/ipcMain.ts @@ -36,7 +36,7 @@ import type { BashToolResult } from "@/common/types/tools"; import { secretsToRecord } from "@/common/types/secrets"; import { DisposableTempDir } from "@/node/services/tempDir"; import { InitStateManager } from "@/node/services/initStateManager"; -import { createRuntime } from "@/node/runtime/runtimeFactory"; +import { createRuntime, IncompatibleRuntimeError } from "@/node/runtime/runtimeFactory"; import type { RuntimeConfig } from "@/common/types/runtime"; import { isSSHRuntime } from "@/common/types/runtime"; import { validateProjectPath } from "@/node/utils/pathUtils"; @@ -1157,6 +1157,16 @@ export class IpcMain { const errorMessage = error instanceof Error ? error.message : JSON.stringify(error, null, 2); log.error("Unexpected error in sendMessage handler:", error); + + // Handle incompatible workspace errors from downgraded configs + if (error instanceof IncompatibleRuntimeError) { + const sendError: SendMessageError = { + type: "incompatible_workspace", + message: error.message, + }; + return { success: false, error: sendError }; + } + const sendError: SendMessageError = { type: "unknown", raw: `Failed to send message: ${errorMessage}`, @@ -1187,6 +1197,16 @@ export class IpcMain { // Convert to SendMessageError for typed error handling const errorMessage = error instanceof Error ? error.message : String(error); log.error("Unexpected error in resumeStream handler:", error); + + // Handle incompatible workspace errors from downgraded configs + if (error instanceof IncompatibleRuntimeError) { + const sendError: SendMessageError = { + type: "incompatible_workspace", + message: error.message, + }; + return { success: false, error: sendError }; + } + const sendError: SendMessageError = { type: "unknown", raw: `Failed to resume stream: ${errorMessage}`,
{message}
+ You can delete this workspace and create a new one, or upgrade mux to use it. +