feat(cases): rename a case and its folder, with a lock and live-session handling

This commit is contained in:
Devvyn committed 2026-10-23 04:12:37 +08:00
1 parent 002de32a08
commit ab0bc0aecd
14 files changed
+884

No files matched your search

+171
View File
@@ -0,0 +1,171 @@
/**
* @fileoverview Renaming a case (its name and its folder) without losing what points at the folder.
*
* A case folder is held in more places than an open file. A running agent has it as its working
* directory and its tmux pane was started `cd`-ed into it; Claude Code keys its conversation history
* by the folder's path (`<config>/projects/<path with every non-alphanumeric as "-">`), so a moved
* folder silently orphans every conversation; on Windows, SMB and some FUSE mounts a directory with an
* open handle simply cannot be renamed (EBUSY / EPERM / EACCES). This module owns the three parts of
* that which are not route logic:
*
* - the in-process lock (`withCaseLock` / `isCasePathLocked`) that keeps a session from starting in a
* folder while it is being moved, and two renames from overlapping;
* - the move itself, retried a few times for a transient lock and turned into a message that says
* what to do when it is a real one;
* - carrying Claude Code's history for the folder over to the new path.
*
* Pure of the server: every function takes the paths it works on.
*
* @module case-rename
*/
import fs from 'node:fs/promises';
import { dirname, join, resolve, sep } from 'node:path';
import { claudeProjectsDir } from './utils/claude-transcript.js';
export type CaseRenameErrorCode =
| 'INVALID' // a name that is not allowed, or the same name
| 'EXISTS' // the new name or folder is already taken
| 'BUSY' // another rename (or a session being stopped) holds the folder
| 'LOCKED' // the OS refused the move: something still has the folder open
| 'CROSS_DEVICE' // the move would cross filesystems
| 'UNSUPPORTED'; // remote and Docker cases
export class CaseRenameError extends Error {
constructor(
readonly code: CaseRenameErrorCode,
message: string
) {
super(message);
this.name = 'CaseRenameError';
}
}
const locked = new Set<string>();
const within = (child: string, parent: string): boolean => child === parent || child.startsWith(parent + sep);
/** True when `path` is, or is inside, a folder that is being renamed right now. */
export function isCasePathLocked(path: string): boolean {
const target = resolve(path);
for (const held of locked) {
if (within(target, held)) return true;
}
return false;
}
/**
* Hold `from` and `to` for the length of `fn`. A second caller touching either (or anything inside
* them) gets `BUSY` instead of racing the move; `Session`'s constructor refuses a working directory
* inside a held folder, so nothing starts in a case that is half-moved.
*/
export async function withCaseLock<T>(from: string, to: string, fn: () => Promise<T>): Promise<T> {
const held = [resolve(from), resolve(to)];
for (const p of held) {
for (const other of locked) {
if (within(p, other) || within(other, p)) {
throw new CaseRenameError('BUSY', 'Another rename is already working on this case. Try again in a moment.');
}
}
}
for (const p of held) locked.add(p);
try {
return await fn();
} finally {
for (const p of held) locked.delete(p);
}
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const LOCK_CODES = new Set(['EBUSY', 'EPERM', 'EACCES']);
/**
* `rename(from, to)` for a folder that must not already exist at `to`. A lock error is retried (a
* handle another process is closing, an antivirus scan) before it is reported as locked.
* `fs.rename` onto an EMPTY existing directory replaces it on POSIX, so the target is checked first.
*/
export async function moveCaseFolder(from: string, to: string, attempts = 4, delayMs = 150): Promise<void> {
if (
await fs.lstat(to).then(
() => true,
() => false
)
) {
throw new CaseRenameError('EXISTS', `A folder named ${to} already exists.`);
}
for (let i = 0; ; i++) {
try {
await fs.rename(from, to);
return;
} catch (err) {
const code = (err as NodeJS.ErrnoException).code ?? '';
if (code === 'EXDEV') {
throw new CaseRenameError(
'CROSS_DEVICE',
'The new folder would be on a different drive or mount; it is not moved.'
);
}
if (LOCK_CODES.has(code)) {
if (i < attempts - 1) {
await sleep(delayMs * (i + 1));
continue;
}
throw new CaseRenameError(
'LOCKED',
`The folder is in use by another program (${code}), so it could not be renamed. Close anything that has it open ` +
'(a terminal, an editor, a file browser or a sync client), or stop the sessions using it, and try again.'
);
}
if (code === 'ENOTEMPTY' || code === 'EEXIST') {
throw new CaseRenameError('EXISTS', `A folder named ${to} already exists.`);
}
throw err;
}
}
}
/** The directory name Claude Code uses for a project path: every non-alphanumeric becomes `-`. */
export function claudeProjectDirName(path: string): string {
return resolve(path).replace(/[^a-zA-Z0-9]/g, '-');
}
/**
* Move Claude Code's history for the old folder to the new one, so `--resume` and the past-sessions list
* still find the conversations. Leaves it alone when the new path already has history (nothing is merged
* or overwritten) or when there is none. Returns whether anything moved.
*/
export async function moveClaudeHistory(oldPath: string, newPath: string, configDir?: string): Promise<boolean> {
const projects = claudeProjectsDir(configDir);
const from = join(projects, claudeProjectDirName(oldPath));
const to = join(projects, claudeProjectDirName(newPath));
if (from === to) return false;
if (
!(await fs.lstat(from).then(
() => true,
() => false
))
)
return false;
if (
await fs.lstat(to).then(
() => true,
() => false
)
)
return false;
try {
await fs.rename(from, to);
return true;
} catch {
return false; // history is a convenience; the folder move already succeeded
}
}
/** Replace `from` by `to` in a list, keeping its position (the case order). */
export function renameInOrder(order: unknown, from: string, to: string): string[] | null {
if (!Array.isArray(order) || !order.includes(from)) return null;
return order.map((n) => (n === from ? to : n)) as string[];
}
/** The folder a linked case moves to: its own parent, the new name. */
export const siblingPath = (path: string, newName: string): string => join(dirname(path), newName);
+6
View File
@@ -151,6 +151,7 @@ import {
} from './session-attachment-history.js';
import type { SessionAttachmentHistoryItem, DisplayModel } from './types/session.js';
import { resolveConfigModel } from './model-config-resolvers.js';
import { isCasePathLocked } from './case-rename.js';
import { legacyConfigForMode } from './session-cli-registry-bridge.js';
import {
launchModelFor,
@@ -948,6 +949,11 @@ export class Session extends EventEmitter {
}
) {
super();
// A case folder being renamed (src/case-rename.ts) must not gain a session mid-move: its pane would
// start in a directory that is about to have a different path.
if (config.workingDir && isCasePathLocked(config.workingDir)) {
throw new Error(`The case folder ${config.workingDir} is being renamed. Try again in a moment.`);
}
this.setMaxListeners(25);
// Default error handler prevents unhandled 'error' events from crashing the process.
+1
View File
@@ -1914,6 +1914,7 @@ const SSE_EVENTS = {
CASE_CREATED: 'case:created',
CASE_LINKED: 'case:linked',
CASE_DELETED: 'case:deleted',
CASE_RENAMED: 'case:renamed',
CASE_ORDER_CHANGED: 'case:order-changed',
DOCKER_EXPORT_COMPLETE: 'docker:exportComplete',
DOCKER_EXPORT_FAILED: 'docker:exportFailed',
+67
View File
@@ -4957,6 +4957,12 @@ Object.assign(CodemanApp.prototype, {
title="${reorderTitle || 'Move up'}" ${isFirst ? 'disabled' : ''}>&#x25B2;</button>
<button class="case-manage-btn" onclick="app.moveCaseDown(${escapeHtml(JSON.stringify(c.name))})"
title="${reorderTitle || 'Move down'}" ${isLast ? 'disabled' : ''}>&#x25BC;</button>
${
c.location === 'docker' || c.location === 'remote'
? ''
: `<button class="case-manage-btn" onclick="app.renameCase(${escapeHtml(JSON.stringify(c.name))})"
title="Rename case (and its folder)">&#x270E;</button>`
}
<button class="case-manage-btn case-manage-btn-delete" onclick="app.deleteCase(${escapeHtml(JSON.stringify(c.name))})"
title="Delete case">&#x2715;</button>
</div>
@@ -4989,6 +4995,67 @@ Object.assign(CodemanApp.prototype, {
await this.saveCaseOrder(reordered.map(c => c.name));
},
/**
* Rename a case and its folder (POST /api/cases/:name/rename). A linked case asks whether the real folder
* should be renamed too (Cancel renames only the alias). When live sessions have the folder open the server
* refuses and lists them; they are only stopped after a second, explicit confirmation.
*/
async renameCase(name) {
const info = (this.cases || []).find((c) => c.name === name);
const entered = prompt(`Rename case "${name}" to (letters, numbers, - and _):`, name);
const newName = (entered || '').trim();
if (!newName || newName === name) return;
if (!/^[a-zA-Z0-9_-]+$/.test(newName)) {
this.showToast('Use only letters, numbers, hyphens and underscores', 'error');
return;
}
const body = { newName };
if (info?.linked) {
body.renameFolder = confirm(
`Also rename the folder on disk?\n\n${info.path}\n\nOK renames the folder to "${newName}" next to it. ` +
`Cancel keeps the folder and only changes the case name.`
);
}
const send = async () => {
const res = await fetch(`/api/cases/${encodeURIComponent(name)}/rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return res.json().catch(() => ({ success: false, error: 'Unexpected response' }));
};
try {
let data = await send();
if (data.requiresStop) {
const list = (data.sessions || []).map((s) => ` - ${s.name}`).join('\n');
if (!confirm(`These sessions have the folder open and will be stopped (nothing is restarted):\n\n${list}\n\nStop them and rename?`)) {
return;
}
body.stopSessions = true;
data = await send();
}
if (!data.success) {
this.showToast(data.error || 'Failed to rename case', 'error');
return;
}
this.showToast(`Case renamed to "${newName}"`, 'success');
// The per-case run options are stored under the case name in this browser.
try {
const stored = localStorage.getItem('caseSettings_' + name);
if (stored !== null) {
localStorage.setItem('caseSettings_' + newName, stored);
localStorage.removeItem('caseSettings_' + name);
}
} catch { /* storage blocked: the options fall back to their defaults */ }
const select = document.getElementById('quickStartCase');
const current = select?.value;
await this.loadQuickStartCases(current === name ? newName : current);
this.renderCaseManageList();
} catch (err) {
this.showToast('Failed to rename case: ' + err.message, 'error');
}
},
async deleteCase(name) {
if (!confirm(`Delete case "${name}"? Linked cases will only be unlinked (folder preserved). Created cases will be permanently deleted.`)) {
return;
+140
View File
@@ -30,6 +30,7 @@ import {
LinkCaseSchema,
CaseOrderSchema,
CaseDefaultGroupSchema,
RenameCaseSchema,
RemoteCaseLinkSchema,
RemoteHostSchema,
DockerCaseLinkSchema,
@@ -52,6 +53,14 @@ import {
import type { GitRemoteProbe, GitUrlParse } from '../../git-clone.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { prepareNewCasePath } from '../case-path.js';
import {
CaseRenameError,
moveCaseFolder,
moveClaudeHistory,
renameInOrder,
siblingPath,
withCaseLock,
} from '../../case-rename.js';
import { boundedPathExists, describeUnknownPath, probePath, probePathKind } from '../../utils/index.js';
import { readAgentCaseMarker, type AgentCaseMarker } from '../../agent-case-marker.js';
import { settingsWriteBlocker, writeHooksConfig } from '../../hooks-config.js';
@@ -1747,6 +1756,137 @@ export function registerCaseRoutes(
}
});
// ========== Rename Case ==========
/**
* Rename a case: its name and, with it, its folder.
*
* - A case under the cases directory IS its folder name, so it always moves.
* - A linked case has an alias that is only a key in linked-cases.json; the real folder moves too only
* with `renameFolder: true` (it can be shared with other tools).
* - Remote and Docker cases are not renamable here: their folder lives on another host or in a container
* whose name derives from the case name.
*
* ⚠️ Moving a folder that sessions have open is the dangerous part (see src/case-rename.ts): live
* sessions in it are listed and refused (409 `requiresStop`) unless `stopSessions` is set, in which
* case they are ended first. The folder is held by an in-process lock for the whole operation, so a
* session cannot start in it half-moved.
*/
app.post('/api/cases/:name/rename', async (req, reply) => {
const { name } = req.params as { name: string };
const user = getAuthUser(req);
const body = parseBody(RenameCaseSchema, req.body);
const newName = body.newName;
const fail = (status: number, code: ApiErrorCode, message: string, extra: Record<string, unknown> = {}) => {
reply.code(status);
return { ...createErrorResponse(code, message), ...extra };
};
if (!SAFE_CASE_NAME.test(name) || !validatePathWithinBase(name, resolveCasesDir(user))) {
return fail(400, ApiErrorCode.INVALID_INPUT, 'Invalid case name');
}
if (newName === name) return fail(400, ApiErrorCode.INVALID_INPUT, 'The new name is the same as the old one');
// Remote and Docker cases first, so a same-named local case cannot be confused for them.
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR);
if (
remoteCases.some((c) => c.name === name && canAccessOwned(user, c.owner)) ||
dockerCases.some((c) => c.name === name && canAccessOwned(user, c.owner))
) {
return fail(400, ApiErrorCode.INVALID_INPUT, 'Remote and Docker cases cannot be renamed here');
}
const casesBase = resolveCasesDir(user);
const linkedCases = await readLinkedCases();
const isLinked = Boolean(linkedCases[name]) && (!isMultiUserMode() || isAdmin(req));
if (isLinked && !isAdmin(req)) return fail(403, ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
const oldPath = isLinked ? linkedCases[name] : join(casesBase, name);
// The new name must be free in every namespace the list merges.
const localTaken = existsSync(join(casesBase, newName));
const taken =
localTaken ||
Boolean(linkedCases[newName]) ||
remoteCases.some((c) => c.name === newName) ||
dockerCases.some((c) => c.name === newName);
if (taken) return fail(409, ApiErrorCode.ALREADY_EXISTS, `A case named "${newName}" already exists`);
const state = await probePath(oldPath);
if (state === 'absent') return fail(404, ApiErrorCode.NOT_FOUND, `Case "${name}" not found`);
if (state === 'unknown') {
return fail(503, ApiErrorCode.OPERATION_FAILED, describeUnknownPath('Case folder', oldPath, { pastCap: true }));
}
const moveFolder = !isLinked || body.renameFolder === true;
const newPath = isLinked ? (moveFolder ? siblingPath(oldPath, newName) : oldPath) : join(casesBase, newName);
if (moveFolder && newPath !== oldPath && (await probePath(newPath, { pastCap: true })) !== 'absent') {
return fail(409, ApiErrorCode.ALREADY_EXISTS, `A folder named ${newPath} already exists`);
}
// Sessions with the folder open. Only a move disturbs them; an alias-only rename leaves them running.
const open = moveFolder
? Array.from(ctx.sessions.values()).filter(
(s) => s.workingDir === oldPath || s.workingDir.startsWith(oldPath + '/')
)
: [];
if (open.some((s) => !canAccessOwned(user, s.owner))) {
return fail(403, ApiErrorCode.FORBIDDEN, "Another user's session has this case open");
}
if (open.length > 0 && !body.stopSessions) {
return fail(
409,
ApiErrorCode.CONFLICT,
`${open.length} live session${open.length === 1 ? ' has' : 's have'} this folder open. Stop them to rename it.`,
{ requiresStop: true, sessions: open.map((s) => ({ id: s.id, name: s.name })) }
);
}
try {
await withCaseLock(oldPath, newPath, async () => {
for (const s of open) await ctx.cleanupSession(s.id, true, 'case_rename');
if (moveFolder) await moveCaseFolder(oldPath, newPath);
try {
if (isLinked) {
const links = await readLinkedCases();
delete links[name];
links[newName] = newPath;
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(links, null, 2));
}
const existing = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
const order = renameInOrder(existing.caseOrder, name, newName);
const last = existing.lastUsedCase === name ? newName : undefined;
if (order || last) {
await fs.writeFile(
SETTINGS_PATH,
JSON.stringify(
{ ...existing, ...(order ? { caseOrder: order } : {}), ...(last ? { lastUsedCase: last } : {}) },
null,
2
)
);
}
} catch (err) {
// The folder moved but the registries did not follow: put it back rather than leave a case
// whose name and folder disagree.
if (moveFolder) await fs.rename(newPath, oldPath).catch(() => {});
throw err;
}
if (moveFolder) await moveClaudeHistory(oldPath, newPath);
});
ctx.broadcast(SseEvent.CaseRenamed, { oldName: name, newName, oldPath, newPath, folderMoved: moveFolder });
return {
success: true,
data: { oldName: name, newName, oldPath, newPath, folderMoved: moveFolder, stoppedSessions: open.length },
};
} catch (err) {
if (err instanceof CaseRenameError) {
const status = err.code === 'BUSY' ? 409 : err.code === 'EXISTS' ? 409 : err.code === 'LOCKED' ? 423 : 400;
return fail(status, ApiErrorCode.OPERATION_FAILED, err.message, { renameCode: err.code });
}
return fail(500, ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
});
// ========== Reorder Cases ==========
app.put('/api/cases/order', async (req, reply): Promise<ApiResponse<{ order: string[] }> | undefined> => {
+15
View File
@@ -1992,6 +1992,21 @@ export const LinkCaseSchema = z.object({
path: safePathSchema,
});
/** POST /api/cases/:name/rename */
export const RenameCaseSchema = z
.object({
newName: z
.string()
.min(1)
.max(100)
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.'),
/** Linked cases only: also rename the real folder on disk (a case under the cases directory always moves). */
renameFolder: z.boolean().optional(),
/** Stop the live sessions that have the folder open, instead of refusing with the list of them. */
stopSessions: z.boolean().optional(),
})
.strict();
/** PUT /api/cases/order */
export const CaseOrderSchema = z.object({
order: z.array(z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format')),
+3
View File
@@ -458,6 +458,8 @@ export const CaseCreated = 'case:created' as const;
export const CaseLinked = 'case:linked' as const;
/** Case deleted or unlinked. */
export const CaseDeleted = 'case:deleted' as const;
/** Case renamed (name and folder). */
export const CaseRenamed = 'case:renamed' as const;
/** Case ordering changed. */
export const CaseOrderChanged = 'case:order-changed' as const;
@@ -702,6 +704,7 @@ export const SseEvent = {
CaseCreated,
CaseLinked,
CaseDeleted,
CaseRenamed,
CaseOrderChanged,
// Docker cases