Skip to content

Commit 5f47458

Browse files
authored
🤖 feat: use workspace name for plan file storage (#1082)
Store plan files at `~/.mux/plans/{projectName}/{workspaceName}.md` instead of `~/.mux/plans/{workspaceId}.md` for better discoverability. ## Changes - `getPlanFilePath` now takes `(workspaceName, projectName)` - Added `getLegacyPlanFilePath` for migration support - Migrate legacy plans to new location on read - Handle plan file rename when workspace is renamed ## New Format - Makes plans discoverable: `ls ~/.mux/plans/mux/` shows all mux workspace plans - Provides global uniqueness via `projectName + workspaceName` - Supports future workspace rename by moving files within project folder ## Legacy Support - On read, checks new path first, falls back to legacy - When legacy found, migrates to new location and deletes old file _Generated with `mux`_
1 parent 038007e commit 5f47458

File tree

8 files changed

+176
-39
lines changed

8 files changed

+176
-39
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { getPlanFilePath, getLegacyPlanFilePath } from "./planStorage";
2+
3+
describe("planStorage", () => {
4+
describe("getPlanFilePath", () => {
5+
it("should return path with project name and workspace name", () => {
6+
const result = getPlanFilePath("fix-plan-a1b2", "mux");
7+
expect(result).toBe("~/.mux/plans/mux/fix-plan-a1b2.md");
8+
});
9+
10+
it("should produce same path for same inputs", () => {
11+
const result1 = getPlanFilePath("fix-bug-x1y2", "myproject");
12+
const result2 = getPlanFilePath("fix-bug-x1y2", "myproject");
13+
expect(result1).toBe(result2);
14+
});
15+
16+
it("should organize plans by project folder", () => {
17+
const result1 = getPlanFilePath("sidebar-a1b2", "mux");
18+
const result2 = getPlanFilePath("auth-c3d4", "other-project");
19+
expect(result1).toBe("~/.mux/plans/mux/sidebar-a1b2.md");
20+
expect(result2).toBe("~/.mux/plans/other-project/auth-c3d4.md");
21+
});
22+
});
23+
24+
describe("getLegacyPlanFilePath", () => {
25+
it("should return path with workspace ID", () => {
26+
const result = getLegacyPlanFilePath("a1b2c3d4e5");
27+
expect(result).toBe("~/.mux/plans/a1b2c3d4e5.md");
28+
});
29+
30+
it("should handle legacy format IDs", () => {
31+
const result = getLegacyPlanFilePath("mux-main");
32+
expect(result).toBe("~/.mux/plans/mux-main.md");
33+
});
34+
});
35+
});

src/common/utils/planStorage.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,25 @@
33
* Returns a path with ~ prefix that works with both local and SSH runtimes.
44
* The runtime will expand ~ to the appropriate home directory.
55
*
6-
* Plan files are stored at: ~/.mux/plans/{workspaceId}.md
6+
* Plan files are stored at: ~/.mux/plans/{projectName}/{workspaceName}.md
7+
*
8+
* Workspace names include a random suffix (e.g., "sidebar-a1b2") making them
9+
* globally unique with high probability. The project folder is for organization
10+
* and discoverability, not uniqueness.
11+
*
12+
* @param workspaceName - Human-readable workspace name with suffix (e.g., "fix-plan-a1b2")
13+
* @param projectName - Project name extracted from project path (e.g., "mux")
14+
*/
15+
export function getPlanFilePath(workspaceName: string, projectName: string): string {
16+
return `~/.mux/plans/${projectName}/${workspaceName}.md`;
17+
}
18+
19+
/**
20+
* Get the legacy plan file path (stored by workspace ID).
21+
* Used for migration: when reading, check new path first, then fall back to legacy.
22+
*
23+
* @param workspaceId - Stable workspace identifier (e.g., "a1b2c3d4e5")
724
*/
8-
export function getPlanFilePath(workspaceId: string): string {
25+
export function getLegacyPlanFilePath(workspaceId: string): string {
926
return `~/.mux/plans/${workspaceId}.md`;
1027
}

src/node/orpc/router.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ import type {
1313
} from "@/common/orpc/types";
1414
import { createAuthMiddleware } from "./authMiddleware";
1515
import { createAsyncMessageQueue } from "@/common/utils/asyncMessageQueue";
16-
import { getPlanFilePath } from "@/common/utils/planStorage";
16+
1717
import { createRuntime } from "@/node/runtime/runtimeFactory";
18-
import { readFileString } from "@/node/utils/runtime/helpers";
18+
import { readPlanFile } from "@/node/utils/runtime/helpers";
1919
import { createAsyncEventQueue } from "@/common/utils/asyncEventIterator";
2020

2121
export const router = (authToken?: string) => {
@@ -592,9 +592,7 @@ export const router = (authToken?: string) => {
592592
.input(schemas.workspace.getPlanContent.input)
593593
.output(schemas.workspace.getPlanContent.output)
594594
.handler(async ({ context, input }) => {
595-
const planPath = getPlanFilePath(input.workspaceId);
596-
597-
// Get workspace metadata to determine runtime
595+
// Get workspace metadata to determine runtime and paths
598596
const metadata = await context.workspaceService.getInfo(input.workspaceId);
599597
if (!metadata) {
600598
return { success: false as const, error: `Workspace not found: ${input.workspaceId}` };
@@ -605,12 +603,17 @@ export const router = (authToken?: string) => {
605603
projectPath: metadata.projectPath,
606604
});
607605

608-
try {
609-
const content = await readFileString(runtime, planPath);
610-
return { success: true as const, data: { content, path: planPath } };
611-
} catch {
612-
return { success: false as const, error: `Plan file not found at ${planPath}` };
606+
const result = await readPlanFile(
607+
runtime,
608+
metadata.name,
609+
metadata.projectName,
610+
input.workspaceId
611+
);
612+
613+
if (!result.exists) {
614+
return { success: false as const, error: `Plan file not found at ${result.path}` };
613615
}
616+
return { success: true as const, data: { content: result.content, path: result.path } };
614617
}),
615618
backgroundBashes: {
616619
subscribe: t

src/node/services/aiService.ts

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ import { EnvHttpProxyAgent, type Dispatcher } from "undici";
5757
import { getPlanFilePath } from "@/common/utils/planStorage";
5858
import { getPlanModeInstruction } from "@/common/utils/ui/modeUtils";
5959
import type { UIMode } from "@/common/types/mode";
60-
import { readFileString } from "@/node/utils/runtime/helpers";
60+
import { readPlanFile } from "@/node/utils/runtime/helpers";
6161

6262
// Export a standalone version of getToolsForModel for use in backend
6363

@@ -992,17 +992,18 @@ export class AIService extends EventEmitter {
992992
// Construct plan mode instruction if in plan mode
993993
// This is done backend-side because we have access to the plan file path
994994
let effectiveAdditionalInstructions = additionalSystemInstructions;
995+
const planFilePath = getPlanFilePath(metadata.name, metadata.projectName);
996+
997+
// Read plan file (handles legacy migration transparently)
998+
const planResult = await readPlanFile(
999+
runtime,
1000+
metadata.name,
1001+
metadata.projectName,
1002+
workspaceId
1003+
);
1004+
9951005
if (mode === "plan") {
996-
const planFilePath = getPlanFilePath(workspaceId);
997-
// Check if plan file exists using runtime (supports both local and SSH)
998-
let planExists = false;
999-
try {
1000-
await runtime.stat(planFilePath);
1001-
planExists = true;
1002-
} catch {
1003-
// File doesn't exist
1004-
}
1005-
const planModeInstruction = getPlanModeInstruction(planFilePath, planExists);
1006+
const planModeInstruction = getPlanModeInstruction(planFilePath, planResult.exists);
10061007
effectiveAdditionalInstructions = additionalSystemInstructions
10071008
? `${planModeInstruction}\n\n${additionalSystemInstructions}`
10081009
: planModeInstruction;
@@ -1015,16 +1016,8 @@ export class AIService extends EventEmitter {
10151016
const lastAssistantMessage = [...filteredMessages]
10161017
.reverse()
10171018
.find((m) => m.role === "assistant");
1018-
if (lastAssistantMessage?.metadata?.mode === "plan") {
1019-
const planFilePath = getPlanFilePath(workspaceId);
1020-
try {
1021-
const content = await readFileString(runtime, planFilePath);
1022-
if (content.trim()) {
1023-
planContentForTransition = content;
1024-
}
1025-
} catch {
1026-
// No plan file exists, proceed without plan content
1027-
}
1019+
if (lastAssistantMessage?.metadata?.mode === "plan" && planResult.content.trim()) {
1020+
planContentForTransition = planResult.content;
10281021
}
10291022
}
10301023

@@ -1141,7 +1134,7 @@ export class AIService extends EventEmitter {
11411134
backgroundProcessManager: this.backgroundProcessManager,
11421135
// Plan mode configuration for path enforcement
11431136
mode: mode as UIMode | undefined,
1144-
planFilePath: mode === "plan" ? getPlanFilePath(workspaceId) : undefined,
1137+
planFilePath: mode === "plan" ? planFilePath : undefined,
11451138
workspaceId,
11461139
// External edit detection callback
11471140
recordFileState,

src/node/services/workspaceService.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ import { createBashTool } from "@/node/services/tools/bash";
4040
import type { BashToolResult } from "@/common/types/tools";
4141
import { secretsToRecord } from "@/common/types/secrets";
4242

43+
import { movePlanFile } from "@/node/utils/runtime/helpers";
44+
4345
/** Maximum number of retry attempts when workspace name collides */
4446
const MAX_WORKSPACE_NAME_COLLISION_RETRIES = 3;
4547

@@ -560,6 +562,9 @@ export class WorkspaceService extends EventEmitter {
560562
return config;
561563
});
562564

565+
// Rename plan file if it exists (uses workspace name, not ID)
566+
await movePlanFile(runtime, oldName, newName, oldMetadata.projectName);
567+
563568
const allMetadataUpdated = await this.config.getAllWorkspaceMetadata();
564569
const updatedMetadata = allMetadataUpdated.find((m) => m.id === workspaceId);
565570
if (!updatedMetadata) {

src/node/utils/runtime/helpers.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Runtime, ExecOptions } from "@/node/runtime/Runtime";
22
import { PlatformPaths } from "@/node/utils/paths.main";
3+
import { getLegacyPlanFilePath, getPlanFilePath } from "@/common/utils/planStorage";
34

45
/**
56
* Convenience helpers for working with streaming Runtime APIs.
@@ -117,3 +118,76 @@ async function streamToString(stream: ReadableStream<Uint8Array>): Promise<strin
117118
reader.releaseLock();
118119
}
119120
}
121+
122+
/**
123+
* Result from reading a plan file with legacy migration support
124+
*/
125+
export interface ReadPlanResult {
126+
/** Plan file content (empty string if file doesn't exist) */
127+
content: string;
128+
/** Whether a plan file exists */
129+
exists: boolean;
130+
/** The canonical plan file path (new format) */
131+
path: string;
132+
}
133+
134+
/**
135+
* Read plan file content, checking new path first then legacy, migrating if needed.
136+
* This handles the transparent migration from ~/.mux/plans/{id}.md to
137+
* ~/.mux/plans/{projectName}/{workspaceName}.md
138+
*/
139+
export async function readPlanFile(
140+
runtime: Runtime,
141+
workspaceName: string,
142+
projectName: string,
143+
workspaceId: string
144+
): Promise<ReadPlanResult> {
145+
const planPath = getPlanFilePath(workspaceName, projectName);
146+
const legacyPath = getLegacyPlanFilePath(workspaceId);
147+
148+
// Try new path first
149+
try {
150+
const content = await readFileString(runtime, planPath);
151+
return { content, exists: true, path: planPath };
152+
} catch {
153+
// Fall back to legacy path
154+
try {
155+
const content = await readFileString(runtime, legacyPath);
156+
// Migrate: move to new location
157+
try {
158+
const planDir = planPath.substring(0, planPath.lastIndexOf("/"));
159+
await execBuffered(runtime, `mkdir -p "${planDir}" && mv "${legacyPath}" "${planPath}"`, {
160+
cwd: "/tmp",
161+
timeout: 5,
162+
});
163+
} catch {
164+
// Migration failed, but we have the content
165+
}
166+
return { content, exists: true, path: planPath };
167+
} catch {
168+
// File doesn't exist at either location
169+
return { content: "", exists: false, path: planPath };
170+
}
171+
}
172+
}
173+
174+
/**
175+
* Move a plan file from one workspace name to another (e.g., during rename).
176+
* Silently succeeds if source file doesn't exist.
177+
*/
178+
export async function movePlanFile(
179+
runtime: Runtime,
180+
oldWorkspaceName: string,
181+
newWorkspaceName: string,
182+
projectName: string
183+
): Promise<void> {
184+
const oldPath = getPlanFilePath(oldWorkspaceName, projectName);
185+
const newPath = getPlanFilePath(newWorkspaceName, projectName);
186+
187+
try {
188+
await runtime.stat(oldPath);
189+
await execBuffered(runtime, `mv "${oldPath}" "${newPath}"`, { cwd: "/tmp", timeout: 5 });
190+
} catch {
191+
// No plan file to move, that's fine
192+
}
193+
}

tests/ipc/fileChangeNotification.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,11 +94,13 @@ describeIntegration("File Change Notification Integration", () => {
9494
if (!createResult.success) throw new Error("Failed to create workspace");
9595

9696
const workspaceId = createResult.metadata.id;
97+
const workspaceName = createResult.metadata.name;
98+
const projectName = createResult.metadata.projectName;
9799

98100
try {
99101
// 2. Get the AgentSession and plan file path
100102
const session = env.services.workspaceService.getOrCreateSession(workspaceId);
101-
const planPath = getPlanFilePath(workspaceId);
103+
const planPath = getPlanFilePath(workspaceName, projectName);
102104

103105
// 3. Create the plan directory and file
104106
const planDir = join(planPath, "..");
@@ -206,11 +208,13 @@ describeIntegration("File Change Notification Integration", () => {
206208
if (!createResult.success) throw new Error("Failed to create workspace");
207209

208210
const workspaceId = createResult.metadata.id;
211+
const workspaceName = createResult.metadata.name;
212+
const projectName = createResult.metadata.projectName;
209213

210214
try {
211215
// 2. Get the AgentSession and plan file path
212216
const session = env.services.workspaceService.getOrCreateSession(workspaceId);
213-
const planPath = getPlanFilePath(workspaceId);
217+
const planPath = getPlanFilePath(workspaceName, projectName);
214218

215219
// 3. Create the plan directory and file
216220
const planDir = join(planPath, "..");

tests/ipc/planCommands.test.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,12 @@ describeIntegration("Plan Commands Integration", () => {
7979
if (!createResult.success) throw new Error("Failed to create workspace");
8080

8181
const workspaceId = createResult.metadata.id;
82+
const workspaceName = createResult.metadata.name;
83+
const projectName = createResult.metadata.projectName;
8284

8385
try {
8486
// Create a plan file
85-
const planPath = getPlanFilePath(workspaceId);
87+
const planPath = getPlanFilePath(workspaceName, projectName);
8688
const expandedPlanPath = expandTilde(planPath);
8789
const planDir = path.dirname(expandedPlanPath);
8890
await fs.mkdir(planDir, { recursive: true });
@@ -116,10 +118,12 @@ describeIntegration("Plan Commands Integration", () => {
116118
if (!createResult.success) throw new Error("Failed to create workspace");
117119

118120
const workspaceId = createResult.metadata.id;
121+
const workspaceName = createResult.metadata.name;
122+
const projectName = createResult.metadata.projectName;
119123

120124
try {
121125
// Create an empty plan file
122-
const planPath = getPlanFilePath(workspaceId);
126+
const planPath = getPlanFilePath(workspaceName, projectName);
123127
const expandedPlanPath = expandTilde(planPath);
124128
const planDir = path.dirname(expandedPlanPath);
125129
await fs.mkdir(planDir, { recursive: true });
@@ -153,10 +157,12 @@ describeIntegration("Plan Commands Integration", () => {
153157
if (!createResult.success) throw new Error("Failed to create workspace");
154158

155159
const workspaceId = createResult.metadata.id;
160+
const workspaceName = createResult.metadata.name;
161+
const projectName = createResult.metadata.projectName;
156162

157163
try {
158164
// Create a plan file
159-
const planPath = getPlanFilePath(workspaceId);
165+
const planPath = getPlanFilePath(workspaceName, projectName);
160166
const planDir = path.dirname(planPath);
161167
await fs.mkdir(planDir, { recursive: true });
162168
await fs.writeFile(planPath, "# Test Plan");

0 commit comments

Comments
 (0)