Skip to content

Commit 36b88ce

Browse files
authored
🤖 feat: copy plan file when forking workspace (#1200)
Copy plan file when forking workspace so the forked workspace can continue working with the same plan context. **Changes:** - Add `copyPlanFile` helper in `src/node/utils/runtime/helpers.ts` (mirrors existing `movePlanFile` pattern) - Call `copyPlanFile` in `workspaceService.fork()` after session files are copied --- <details> <summary>📋 Implementation Plan</summary> # Plan: Copy Plan File on Workspace Fork ## Summary Add functionality to copy the source workspace's plan file when forking, allowing the forked workspace to continue working with the same plan context. ## Problem When a workspace is forked via `/fork`, the following files are copied: - `chat.jsonl` (chat history) - `partial.json` (partial streaming state) - `session-usage.json` (usage tracking) However, the **plan file** (`~/.mux/plans/{project}/{workspace}.md`) is **not** copied. This means the forked workspace starts without the plan context, even though it inherits the chat history where the plan was discussed. ## Solution Add a `copyPlanFile` helper function and invoke it during the fork operation in `workspaceService.fork()`. ## Implementation ### 1. Add `copyPlanFile` helper in `src/node/utils/runtime/helpers.ts` Create a new function following the same pattern as `movePlanFile`: ```typescript /** * Copy a plan file from one workspace to another (e.g., during fork). * Silently succeeds if source file doesn't exist. */ export async function copyPlanFile( runtime: Runtime, sourceWorkspaceName: string, targetWorkspaceName: string, projectName: string ): Promise<void> { const sourcePath = getPlanFilePath(sourceWorkspaceName, projectName); const targetPath = getPlanFilePath(targetWorkspaceName, projectName); try { await runtime.stat(sourcePath); await execBuffered(runtime, `cp "${sourcePath}" "${targetPath}"`, { cwd: "/tmp", timeout: 5 }); } catch { // No plan file to copy, that's fine } } ``` ### 2. Update `src/node/services/workspaceService.ts` 1. Add import for `copyPlanFile`: ```typescript import { movePlanFile, copyPlanFile } from "@/node/utils/runtime/helpers"; ``` 2. Call `copyPlanFile` in the `fork()` method after successfully copying session files (~line 924): ```typescript // Copy plan file if it exists (uses workspace name, not ID) await copyPlanFile(runtime, sourceMetadata.name, newName, projectName); ``` ## File Changes | File | Change | |------|--------| | `src/node/utils/runtime/helpers.ts` | Add `copyPlanFile` function (~15 LoC) | | `src/node/services/workspaceService.ts` | Import + call `copyPlanFile` (~3 LoC) | **Net LoC estimate:** ~+18 lines ## Testing The existing test patterns for `movePlanFile` in rename operations provide a template. The change is straightforward: - `copyPlanFile` follows the same error-handling pattern as `movePlanFile` - Silently succeeds if no plan file exists (idempotent) - Uses runtime abstraction, so works for both local and SSH runtimes Manual verification: 1. Create a workspace with a plan 2. Fork it with `/fork new-name` 3. Verify the new workspace has a copy of the plan file at `~/.mux/plans/{project}/new-name.md` ## Edge Cases - **No plan file exists**: Silent success (matches `movePlanFile` behavior) - **Target file already exists**: `cp` will overwrite, which is correct for forks - **SSH runtime**: Works via runtime abstraction (same as existing `movePlanFile`) </details> --- _Generated with `mux` • Model: `anthropic:claude-opus-4-5` • Thinking: `high`_ Signed-off-by: Thomas Kosiewski <[email protected]>
1 parent cc478e5 commit 36b88ce

File tree

2 files changed

+44
-2
lines changed

2 files changed

+44
-2
lines changed

src/node/services/workspaceService.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ import { createBashTool } from "@/node/services/tools/bash";
5656
import type { AskUserQuestionToolSuccessResult, BashToolResult } from "@/common/types/tools";
5757
import { secretsToRecord } from "@/common/types/secrets";
5858

59-
import { movePlanFile } from "@/node/utils/runtime/helpers";
59+
import { movePlanFile, copyPlanFile } from "@/node/utils/runtime/helpers";
6060

6161
/** Maximum number of retry attempts when workspace name collides */
6262
const MAX_WORKSPACE_NAME_COLLISION_RETRIES = 3;
@@ -960,6 +960,9 @@ export class WorkspaceService extends EventEmitter {
960960
return Err(`Failed to copy chat history: ${message}`);
961961
}
962962

963+
// Copy plan file if it exists (checks both new and legacy paths)
964+
await copyPlanFile(runtime, sourceMetadata.name, sourceWorkspaceId, newName, projectName);
965+
963966
// Compute namedWorkspacePath for frontend metadata
964967
const namedWorkspacePath = runtime.getWorkspacePath(foundProjectPath, newName);
965968

src/node/utils/runtime/helpers.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,8 +190,47 @@ export async function movePlanFile(
190190

191191
try {
192192
await runtime.stat(oldPath);
193-
await execBuffered(runtime, `mv "${oldPath}" "${newPath}"`, { cwd: "/tmp", timeout: 5 });
193+
// Resolve tildes to absolute paths - bash doesn't expand ~ inside quotes
194+
const resolvedOldPath = await runtime.resolvePath(oldPath);
195+
const resolvedNewPath = await runtime.resolvePath(newPath);
196+
await execBuffered(runtime, `mv "${resolvedOldPath}" "${resolvedNewPath}"`, {
197+
cwd: "/tmp",
198+
timeout: 5,
199+
});
194200
} catch {
195201
// No plan file to move, that's fine
196202
}
197203
}
204+
205+
/**
206+
* Copy a plan file from one workspace to another (e.g., during fork).
207+
* Checks both new path format and legacy path format for the source.
208+
* Silently succeeds if source file doesn't exist at either location.
209+
*/
210+
export async function copyPlanFile(
211+
runtime: Runtime,
212+
sourceWorkspaceName: string,
213+
sourceWorkspaceId: string,
214+
targetWorkspaceName: string,
215+
projectName: string
216+
): Promise<void> {
217+
const sourcePath = getPlanFilePath(sourceWorkspaceName, projectName);
218+
const legacySourcePath = getLegacyPlanFilePath(sourceWorkspaceId);
219+
const targetPath = getPlanFilePath(targetWorkspaceName, projectName);
220+
221+
// Prefer the new layout, but fall back to the legacy layout.
222+
//
223+
// Note: we intentionally use runtime file I/O instead of `cp` because:
224+
// 1) bash doesn't expand ~ inside quotes
225+
// 2) the target per-project plan directory may not exist yet
226+
// 3) runtime.writeFile() already handles directory creation + tilde expansion
227+
for (const candidatePath of [sourcePath, legacySourcePath]) {
228+
try {
229+
const content = await readFileString(runtime, candidatePath);
230+
await writeFileString(runtime, targetPath, content);
231+
return;
232+
} catch {
233+
// Try next candidate
234+
}
235+
}
236+
}

0 commit comments

Comments
 (0)