Skip to content

Commit b4d4b11

Browse files
committed
🤖 fix: add downgrade compatibility for incompatible runtime configs
When users upgrade to a version with new runtime types (like 'local' without srcBaseDir for project-dir mode, or new 'worktree' type) and then downgrade, the old version should show a clear error instead of crashing. Changes: - Add IncompatibleRuntimeError for workspaces from newer mux versions - Add isIncompatibleRuntimeConfig helper to detect future configs - Add 'incompatible_workspace' SendMessageError type - Handle error in sendMessage/resumeStream handlers - Show helpful toast with 'upgrade mux' suggestion - Mark incompatible_workspace as non-retryable This prepares for #824 (Local/Worktree runtime distinction) by ensuring users can safely downgrade without losing access to their other workspaces.
1 parent 284dbc7 commit b4d4b11

File tree

9 files changed

+182
-1
lines changed

9 files changed

+182
-1
lines changed

src/browser/components/ChatInputToasts.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,5 +69,18 @@ describe("ChatInputToasts", () => {
6969
expect(toast.title).toBe("Message Send Failed");
7070
expect(toast.message).toContain("unexpected error");
7171
});
72+
73+
test("should create toast for incompatible_workspace error", () => {
74+
const error: SendMessageError = {
75+
type: "incompatible_workspace",
76+
message: "This workspace uses a runtime configuration from a newer version of mux.",
77+
};
78+
79+
const toast = createErrorToast(error);
80+
81+
expect(toast.type).toBe("error");
82+
expect(toast.title).toBe("Incompatible Workspace");
83+
expect(toast.message).toContain("newer version");
84+
});
7285
});
7386
});

src/browser/components/ChatInputToasts.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,21 @@ export const createErrorToast = (error: SendMessageErrorType): Toast => {
195195
};
196196
}
197197

198+
case "incompatible_workspace": {
199+
return {
200+
id: Date.now().toString(),
201+
type: "error",
202+
title: "Incompatible Workspace",
203+
message: error.message,
204+
solution: (
205+
<>
206+
<SolutionLabel>Solution:</SolutionLabel>
207+
Upgrade mux to use this workspace, or delete it and create a new one.
208+
</>
209+
),
210+
};
211+
}
212+
198213
case "unknown":
199214
default: {
200215
const formatted = formatSendMessageError(error);

src/browser/utils/messages/retryEligibility.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,4 +594,12 @@ describe("isNonRetryableSendError", () => {
594594
};
595595
expect(isNonRetryableSendError(error)).toBe(false);
596596
});
597+
598+
it("returns true for incompatible_workspace error", () => {
599+
const error: SendMessageError = {
600+
type: "incompatible_workspace",
601+
message: "This workspace uses a runtime configuration from a newer version of mux.",
602+
};
603+
expect(isNonRetryableSendError(error)).toBe(true);
604+
});
597605
});

src/browser/utils/messages/retryEligibility.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export function isNonRetryableSendError(error: SendMessageError): boolean {
5252
case "api_key_not_found": // Missing API key - user must configure
5353
case "provider_not_supported": // Unsupported provider - user must switch
5454
case "invalid_model_string": // Bad model format - user must fix
55+
case "incompatible_workspace": // Workspace from newer mux version - user must upgrade
5556
return true;
5657
case "unknown":
5758
return false; // Unknown errors might be transient

src/common/types/errors.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export type SendMessageError =
1212
| { type: "api_key_not_found"; provider: string }
1313
| { type: "provider_not_supported"; provider: string }
1414
| { type: "invalid_model_string"; message: string }
15+
| { type: "incompatible_workspace"; message: string }
1516
| { type: "unknown"; raw: string };
1617

1718
/**

src/common/utils/errors/formatSendError.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ export function formatSendMessageError(error: SendMessageError): FormattedError
3232
message: error.message,
3333
};
3434

35+
case "incompatible_workspace":
36+
return {
37+
message: error.message,
38+
};
39+
3540
case "unknown":
3641
return {
3742
message: error.raw || "An unexpected error occurred",
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { describe, expect, it } from "bun:test";
2+
import {
3+
isIncompatibleRuntimeConfig,
4+
createRuntime,
5+
IncompatibleRuntimeError,
6+
} from "./runtimeFactory";
7+
import type { RuntimeConfig } from "@/common/types/runtime";
8+
9+
describe("isIncompatibleRuntimeConfig", () => {
10+
it("returns false for undefined config", () => {
11+
expect(isIncompatibleRuntimeConfig(undefined)).toBe(false);
12+
});
13+
14+
it("returns false for valid local config with srcBaseDir", () => {
15+
const config: RuntimeConfig = {
16+
type: "local",
17+
srcBaseDir: "~/.mux/src",
18+
};
19+
expect(isIncompatibleRuntimeConfig(config)).toBe(false);
20+
});
21+
22+
it("returns false for valid SSH config", () => {
23+
const config: RuntimeConfig = {
24+
type: "ssh",
25+
host: "example.com",
26+
srcBaseDir: "/home/user/mux",
27+
};
28+
expect(isIncompatibleRuntimeConfig(config)).toBe(false);
29+
});
30+
31+
it("returns true for local config without srcBaseDir (future project-dir mode)", () => {
32+
// Simulate a config from a future version that has type: "local" without srcBaseDir
33+
// This bypasses TypeScript checks to simulate runtime data from newer versions
34+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
35+
const config = { type: "local" } as RuntimeConfig;
36+
expect(isIncompatibleRuntimeConfig(config)).toBe(true);
37+
});
38+
39+
it("returns true for local config with empty srcBaseDir", () => {
40+
// Simulate a malformed config - empty srcBaseDir shouldn't be valid
41+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
42+
const config = { type: "local", srcBaseDir: "" } as RuntimeConfig;
43+
expect(isIncompatibleRuntimeConfig(config)).toBe(true);
44+
});
45+
46+
it("returns true for unknown runtime type (future types like worktree)", () => {
47+
// Simulate a config from a future version with new type
48+
49+
const config = { type: "worktree", srcBaseDir: "~/.mux/src" } as unknown as RuntimeConfig;
50+
expect(isIncompatibleRuntimeConfig(config)).toBe(true);
51+
});
52+
});
53+
54+
describe("createRuntime", () => {
55+
it("creates LocalRuntime for valid local config", () => {
56+
const config: RuntimeConfig = {
57+
type: "local",
58+
srcBaseDir: "/tmp/test-src",
59+
};
60+
const runtime = createRuntime(config);
61+
expect(runtime).toBeDefined();
62+
});
63+
64+
it("throws IncompatibleRuntimeError for local config without srcBaseDir", () => {
65+
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
66+
const config = { type: "local" } as RuntimeConfig;
67+
expect(() => createRuntime(config)).toThrow(IncompatibleRuntimeError);
68+
expect(() => createRuntime(config)).toThrow(/newer version of mux/);
69+
});
70+
71+
it("throws IncompatibleRuntimeError for unknown runtime type", () => {
72+
const config = { type: "worktree", srcBaseDir: "~/.mux/src" } as unknown as RuntimeConfig;
73+
expect(() => createRuntime(config)).toThrow(IncompatibleRuntimeError);
74+
expect(() => createRuntime(config)).toThrow(/newer version of mux/);
75+
});
76+
});

src/node/runtime/runtimeFactory.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,52 @@ import { LocalRuntime } from "./LocalRuntime";
33
import { SSHRuntime } from "./SSHRuntime";
44
import type { RuntimeConfig } from "@/common/types/runtime";
55

6+
/**
7+
* Error thrown when a workspace has an incompatible runtime configuration,
8+
* typically from a newer version of mux that added new runtime types.
9+
*/
10+
export class IncompatibleRuntimeError extends Error {
11+
constructor(message: string) {
12+
super(message);
13+
this.name = "IncompatibleRuntimeError";
14+
}
15+
}
16+
17+
/**
18+
* Check if a runtime config is from a newer version and incompatible.
19+
*
20+
* This handles downgrade compatibility: if a user upgrades to a version
21+
* with new runtime types (e.g., "local" without srcBaseDir for project-dir mode),
22+
* then downgrades, those workspaces should show a clear error rather than crashing.
23+
*/
24+
export function isIncompatibleRuntimeConfig(config: RuntimeConfig | undefined): boolean {
25+
if (!config) {
26+
return false;
27+
}
28+
// Future versions may add "local" without srcBaseDir (project-dir mode)
29+
// or new types like "worktree". Detect these as incompatible.
30+
if (config.type === "local" && !("srcBaseDir" in config && config.srcBaseDir)) {
31+
return true;
32+
}
33+
// Unknown types from future versions
34+
if (config.type !== "local" && config.type !== "ssh") {
35+
return true;
36+
}
37+
return false;
38+
}
39+
640
/**
741
* Create a Runtime instance based on the configuration
842
*/
943
export function createRuntime(config: RuntimeConfig): Runtime {
44+
// Check for incompatible configs from newer versions
45+
if (isIncompatibleRuntimeConfig(config)) {
46+
throw new IncompatibleRuntimeError(
47+
`This workspace uses a runtime configuration from a newer version of mux. ` +
48+
`Please upgrade mux to use this workspace.`
49+
);
50+
}
51+
1052
switch (config.type) {
1153
case "local":
1254
return new LocalRuntime(config.srcBaseDir);

src/node/services/ipcMain.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ import type { BashToolResult } from "@/common/types/tools";
3636
import { secretsToRecord } from "@/common/types/secrets";
3737
import { DisposableTempDir } from "@/node/services/tempDir";
3838
import { InitStateManager } from "@/node/services/initStateManager";
39-
import { createRuntime } from "@/node/runtime/runtimeFactory";
39+
import { createRuntime, IncompatibleRuntimeError } from "@/node/runtime/runtimeFactory";
4040
import type { RuntimeConfig } from "@/common/types/runtime";
4141
import { isSSHRuntime } from "@/common/types/runtime";
4242
import { validateProjectPath } from "@/node/utils/pathUtils";
@@ -1157,6 +1157,16 @@ export class IpcMain {
11571157
const errorMessage =
11581158
error instanceof Error ? error.message : JSON.stringify(error, null, 2);
11591159
log.error("Unexpected error in sendMessage handler:", error);
1160+
1161+
// Handle incompatible workspace errors from downgraded configs
1162+
if (error instanceof IncompatibleRuntimeError) {
1163+
const sendError: SendMessageError = {
1164+
type: "incompatible_workspace",
1165+
message: error.message,
1166+
};
1167+
return { success: false, error: sendError };
1168+
}
1169+
11601170
const sendError: SendMessageError = {
11611171
type: "unknown",
11621172
raw: `Failed to send message: ${errorMessage}`,
@@ -1187,6 +1197,16 @@ export class IpcMain {
11871197
// Convert to SendMessageError for typed error handling
11881198
const errorMessage = error instanceof Error ? error.message : String(error);
11891199
log.error("Unexpected error in resumeStream handler:", error);
1200+
1201+
// Handle incompatible workspace errors from downgraded configs
1202+
if (error instanceof IncompatibleRuntimeError) {
1203+
const sendError: SendMessageError = {
1204+
type: "incompatible_workspace",
1205+
message: error.message,
1206+
};
1207+
return { success: false, error: sendError };
1208+
}
1209+
11901210
const sendError: SendMessageError = {
11911211
type: "unknown",
11921212
raw: `Failed to resume stream: ${errorMessage}`,

0 commit comments

Comments
 (0)