Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions src/browser/App.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <AppLoader />;
};

return <AppWithIncompatibleWorkspace />;
},
};

/**
* Story demonstrating all possible UI indicators in the project sidebar.
*
Expand Down
3 changes: 3 additions & 0 deletions src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,9 @@ function AppInner() {
runtimeConfig={
workspaceMetadata.get(selectedWorkspace.workspaceId)?.runtimeConfig
}
incompatibleRuntime={
workspaceMetadata.get(selectedWorkspace.workspaceId)?.incompatibleRuntime
}
/>
</ErrorBoundary>
) : creationProjectPath ? (
Expand Down
31 changes: 31 additions & 0 deletions src/browser/components/AIView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<AIViewProps> = ({
Expand Down Expand Up @@ -607,8 +609,37 @@ const AIViewInner: React.FC<AIViewProps> = ({
);
};

/**
* 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,
}) => (
<div className={cn("flex h-full w-full flex-col items-center justify-center p-8", className)}>
<div className="max-w-md text-center">
<div className="mb-4 text-4xl">⚠️</div>
<h2 className="mb-2 text-xl font-semibold text-[var(--color-text-primary)]">
Incompatible Workspace
</h2>
<p className="mb-4 text-[var(--color-text-secondary)]">{message}</p>
<p className="text-sm text-[var(--color-text-tertiary)]">
You can delete this workspace and create a new one, or upgrade mux to use it.
</p>
</div>
</div>
);

// Wrapper component that provides the mode and thinking contexts
export const AIView: React.FC<AIViewProps> = (props) => {
// Early return for incompatible workspaces - no hooks called in this path
if (props.incompatibleRuntime) {
return (
<IncompatibleWorkspaceView message={props.incompatibleRuntime} className={props.className} />
);
}

return (
<ModeProvider workspaceId={props.workspaceId}>
<ProviderOptionsProvider>
Expand Down
13 changes: 13 additions & 0 deletions src/browser/components/ChatInputToasts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
});
15 changes: 15 additions & 0 deletions src/browser/components/ChatInputToasts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: (
<>
<SolutionLabel>Solution:</SolutionLabel>
Upgrade mux to use this workspace, or delete it and create a new one.
</>
),
};
}

case "unknown":
default: {
const formatted = formatSendMessageError(error);
Expand Down
8 changes: 8 additions & 0 deletions src/browser/utils/messages/retryEligibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
1 change: 1 addition & 0 deletions src/browser/utils/messages/retryEligibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/common/types/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

/**
Expand Down
7 changes: 7 additions & 0 deletions src/common/types/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions src/common/utils/errors/formatSendError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
31 changes: 31 additions & 0 deletions src/common/utils/runtimeCompatibility.ts
Original file line number Diff line number Diff line change
@@ -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;
}
12 changes: 11 additions & 1 deletion src/node/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
}

/**
Expand Down
73 changes: 73 additions & 0 deletions src/node/runtime/runtimeFactory.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
Loading