Merge pull request #680: feat(cases): rename a case and its folder, with a lock and live-session handling

This commit is contained in:
Codeman maintainer committed 2026-10-24 15:27:46 +02:00
commit 2fdbe786b3
18 files changed
+1454 -45

No files matched your search

+220
View File
@@ -0,0 +1,220 @@
/**
* @fileoverview The Rename dialog's flow in session-ui.js (renameCase / submitRenameCase): it is the app's
* own modal, not a browser prompt; a linked case offers the folder checkbox; live sessions are listed inside
* the dialog and only stopped after a second press; server errors stay in the dialog. Evaluates the real file
* against a stub DOM.
*/
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const SRC = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8');
const HTML = readFileSync(new URL('../src/web/public/index.html', import.meta.url), 'utf-8');
type El = {
value: string;
textContent: string;
checked: boolean;
disabled: boolean;
classes: Set<string>;
classList: {
add(c: string): void;
remove(c: string): void;
toggle(c: string, on?: boolean): void;
contains(c: string): boolean;
};
focus(): void;
select(): void;
};
const el = (hidden = false): El => {
const classes = new Set<string>(hidden ? ['hidden'] : []);
return {
value: '',
textContent: '',
checked: false,
disabled: false,
classes,
classList: {
add: (c) => void classes.add(c),
remove: (c) => void classes.delete(c),
toggle: (c, on) => void ((on ?? !classes.has(c)) ? classes.add(c) : classes.delete(c)),
contains: (c) => classes.has(c),
},
focus() {},
select() {},
};
};
function makeApp(opts: { responses: unknown[]; cases: unknown[]; current?: string }) {
const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
const toasts: string[] = [];
const store = new Map<string, string>([['caseSettings_alpha', '{"agentTeams":true}']]);
const queue = [...opts.responses];
const ids = [
'renameCaseModal',
'renameCaseInput',
'renameCaseLabel',
'renameCaseFolderRow',
'renameCaseFolder',
'renameCaseFolderHint',
'renameCaseNote',
'renameCaseError',
'renameCaseSubmit',
'quickStartCase',
];
const els: Record<string, El> = Object.fromEntries(ids.map((id) => [id, el()]));
els.quickStartCase.value = opts.current ?? 'alpha';
function CodemanApp(this: unknown) {}
const ctx = vm.createContext({
CodemanApp,
window: {},
document: { getElementById: (id: string) => els[id] ?? null },
localStorage: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
},
fetch: async (url: string, init: { body: string }) => {
calls.push({ url, body: JSON.parse(init.body) });
return { json: async () => queue.shift() };
},
escapeHtml: (s: string) => s,
});
vm.runInContext(SRC, ctx);
const app = Object.create((CodemanApp as unknown as { prototype: object }).prototype) as Record<string, any>;
const refreshed: unknown[] = [];
Object.assign(app, {
cases: opts.cases,
showToast: (m: string) => void toasts.push(m),
loadQuickStartCases: async (n: unknown) => void refreshed.push(n),
renderCaseManageList: () => void refreshed.push('render'),
});
return { app, els, calls, toasts, store, refreshed };
}
const ok = { success: true, data: {} };
describe('the Rename dialog is part of the page, not a browser prompt', () => {
it('has its modal in index.html, with the name, folder and error elements the code drives', () => {
for (const id of [
'renameCaseModal',
'renameCaseInput',
'renameCaseFolder',
'renameCaseError',
'renameCaseSubmit',
]) {
expect(HTML).toContain(`id="${id}"`);
}
const fn = SRC.slice(SRC.indexOf('renameCase(name)'), SRC.indexOf('async deleteCase(name)'));
expect(fn).not.toMatch(/\b(prompt|confirm|alert)\(/);
});
});
describe('renameCase / submitRenameCase', () => {
it('opens pre-filled, renames a local case, moves its run options and reselects it under the new name', async () => {
const t = makeApp({ responses: [ok], cases: [{ name: 'alpha', location: 'local' }] });
t.app.renameCase('alpha');
expect(t.els.renameCaseModal.classes.has('active')).toBe(true);
expect(t.els.renameCaseInput.value).toBe('alpha');
expect(t.els.renameCaseFolderRow.classes.has('hidden')).toBe(true);
t.els.renameCaseInput.value = ' beta ';
await t.app.submitRenameCase();
expect(t.calls).toEqual([{ url: '/api/cases/alpha/rename', body: { newName: 'beta' } }]);
expect(t.els.renameCaseModal.classes.has('active')).toBe(false);
expect(t.store.get('caseSettings_beta')).toBe('{"agentTeams":true}');
expect(t.store.has('caseSettings_alpha')).toBe(false);
expect(t.refreshed).toEqual(['beta', 'render']);
});
it('keeps the dialog open with a message for the same name or a name that is not allowed, and sends nothing', async () => {
for (const entered of ['', 'alpha', 'a b', '../x']) {
const t = makeApp({ responses: [], cases: [{ name: 'alpha' }] });
t.app.renameCase('alpha');
t.els.renameCaseInput.value = entered;
await t.app.submitRenameCase();
expect(t.calls, entered).toEqual([]);
expect(t.els.renameCaseError.classes.has('hidden'), entered).toBe(false);
expect(t.els.renameCaseModal.classes.has('active'), entered).toBe(true);
}
});
it('shows the folder checkbox for a linked case and sends its state', async () => {
const t = makeApp({ responses: [ok], cases: [{ name: 'alpha', linked: true, path: '/srv/app' }] });
t.app.renameCase('alpha');
expect(t.els.renameCaseFolderRow.classes.has('hidden')).toBe(false);
expect(t.els.renameCaseFolderHint.textContent).toContain('/srv/app');
t.els.renameCaseInput.value = 'beta';
t.els.renameCaseFolder.checked = true;
await t.app.submitRenameCase();
expect(t.calls[0].body).toEqual({ newName: 'beta', renameFolder: true });
const alias = makeApp({ responses: [ok], cases: [{ name: 'alpha', linked: true, path: '/srv/app' }] });
alias.app.renameCase('alpha');
alias.els.renameCaseInput.value = 'beta';
await alias.app.submitRenameCase();
expect(alias.calls[0].body).toEqual({ newName: 'beta', renameFolder: false });
});
it('lists the live sessions in the dialog and stops them only on the second press', async () => {
const refusal = {
success: false,
error: '1 live session has this folder open.',
requiresStop: true,
sessions: [{ id: 's', name: 'w1-alpha' }],
};
const t = makeApp({ responses: [refusal, ok], cases: [{ name: 'alpha' }] });
t.app.renameCase('alpha');
t.els.renameCaseInput.value = 'beta';
await t.app.submitRenameCase();
expect(t.els.renameCaseError.textContent).toContain('w1-alpha');
// "stop" closes them through the server's session cleanup: say that their tabs go away
expect(t.els.renameCaseError.textContent).toContain('They will be closed (their tabs go away');
expect(t.els.renameCaseSubmit.textContent).toBe('Stop sessions and rename');
expect(t.els.renameCaseModal.classes.has('active')).toBe(true);
expect(t.calls).toHaveLength(1);
await t.app.submitRenameCase();
expect(t.calls.map((c) => c.body)).toEqual([{ newName: 'beta' }, { newName: 'beta', stopSessions: true }]);
expect(t.els.renameCaseModal.classes.has('active')).toBe(false);
});
it('says how many sessions were already closed when the rename fails after closing them', async () => {
const refusal = { success: false, error: 'busy', requiresStop: true, sessions: [{ id: 's', name: 'w1-alpha' }] };
const failed = { success: false, error: 'The registry could not be written.', stoppedSessions: 2 };
const t = makeApp({ responses: [refusal, failed], cases: [{ name: 'alpha' }] });
t.app.renameCase('alpha');
t.els.renameCaseInput.value = 'beta';
await t.app.submitRenameCase();
await t.app.submitRenameCase();
expect(t.els.renameCaseError.textContent).toBe(
'The registry could not be written. (2 sessions were already closed.)'
);
expect(t.els.renameCaseModal.classes.has('active')).toBe(true);
});
it('cancelling the dialog stops nothing and renames nothing', async () => {
const t = makeApp({ responses: [], cases: [{ name: 'alpha' }] });
t.app.renameCase('alpha');
t.app.closeRenameCase();
expect(t.els.renameCaseModal.classes.has('active')).toBe(false);
await t.app.submitRenameCase(); // nothing open: ignored
expect(t.calls).toEqual([]);
});
it('shows the server’s message inside the dialog when the folder is locked, and leaves the lists alone', async () => {
const locked = { success: false, error: 'The folder is in use by another program (EBUSY).' };
const t = makeApp({ responses: [locked], cases: [{ name: 'alpha' }] });
t.app.renameCase('alpha');
t.els.renameCaseInput.value = 'beta';
await t.app.submitRenameCase();
expect(t.els.renameCaseError.textContent).toBe('The folder is in use by another program (EBUSY).');
expect(t.els.renameCaseModal.classes.has('active')).toBe(true);
expect(t.els.renameCaseSubmit.disabled).toBe(false); // the name can be fixed and tried again
expect(t.toasts).toEqual([]);
expect(t.refreshed).toEqual([]);
expect(t.store.has('caseSettings_alpha')).toBe(true);
});
});
+212
View File
@@ -0,0 +1,212 @@
/**
* @fileoverview src/case-rename.ts: the in-process lock, the retried folder move and Claude history
* migration. Real filesystem under test/setup.ts's temp HOME. Port: N/A.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs';
import fsp from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
CaseRenameError,
claudeProjectDirName,
isCasePathLocked,
moveCaseFolder,
moveClaudeHistory,
renameInOrder,
siblingPath,
withCaseLock,
} from '../src/case-rename.js';
import { Session } from '../src/session.js';
const roots: string[] = [];
const tmp = () => {
const dir = mkdtempSync(join(tmpdir(), 'case-rename-'));
roots.push(dir);
return dir;
};
afterEach(async () => {
vi.restoreAllMocks();
for (const dir of roots.splice(0)) await fsp.rm(dir, { recursive: true, force: true });
});
describe('withCaseLock', () => {
it('holds both paths and everything inside them for the length of the call, then lets go', async () => {
const root = tmp();
const from = join(root, 'old');
const to = join(root, 'new');
let during: boolean[] = [];
await withCaseLock(from, to, async () => {
during = [
isCasePathLocked(from),
isCasePathLocked(join(from, 'src')),
isCasePathLocked(to),
isCasePathLocked(root),
];
});
expect(during).toEqual([true, true, true, false]);
expect(isCasePathLocked(from)).toBe(false);
expect(isCasePathLocked(to)).toBe(false);
});
it('refuses an overlapping rename with BUSY and releases on failure', async () => {
const root = tmp();
await withCaseLock(join(root, 'a'), join(root, 'b'), async () => {
await expect(withCaseLock(join(root, 'b'), join(root, 'c'), async () => {})).rejects.toMatchObject({
code: 'BUSY',
});
await expect(withCaseLock(join(root, 'a', 'inner'), join(root, 'x'), async () => {})).rejects.toBeInstanceOf(
CaseRenameError
);
});
await expect(
withCaseLock(join(root, 'a'), join(root, 'b'), async () => {
throw new Error('boom');
})
).rejects.toThrow('boom');
expect(isCasePathLocked(join(root, 'a'))).toBe(false);
});
it('keeps a session from starting in a folder that is being moved', async () => {
const root = tmp();
const from = join(root, 'case');
mkdirSync(from);
await withCaseLock(from, join(root, 'renamed'), async () => {
expect(() => new Session({ workingDir: from })).toThrow(/being renamed/);
expect(() => new Session({ workingDir: join(from, 'sub') })).toThrow(/being renamed/);
});
});
});
describe('moveCaseFolder', () => {
it('moves a folder and its contents', async () => {
const root = tmp();
mkdirSync(join(root, 'old'));
writeFileSync(join(root, 'old', 'CLAUDE.md'), 'x');
await moveCaseFolder(join(root, 'old'), join(root, 'new'));
expect(existsSync(join(root, 'old'))).toBe(false);
expect(existsSync(join(root, 'new', 'CLAUDE.md'))).toBe(true);
});
it('never replaces an existing folder, even an empty one that rename would silently overwrite', async () => {
const root = tmp();
mkdirSync(join(root, 'old'));
mkdirSync(join(root, 'taken'));
await expect(moveCaseFolder(join(root, 'old'), join(root, 'taken'))).rejects.toMatchObject({ code: 'EXISTS' });
expect(existsSync(join(root, 'old'))).toBe(true);
});
it('retries a lock error, and succeeds once the other program lets go', async () => {
const root = tmp();
mkdirSync(join(root, 'old'));
const real = fsp.rename.bind(fsp);
let calls = 0;
vi.spyOn(fsp, 'rename').mockImplementation(async (a, b) => {
if (++calls < 3) throw Object.assign(new Error('busy'), { code: 'EBUSY' });
return real(a, b);
});
await moveCaseFolder(join(root, 'old'), join(root, 'new'), 4, 1);
expect(calls).toBe(3);
expect(existsSync(join(root, 'new'))).toBe(true);
});
it('reports a lock that never clears as LOCKED, with what to do about it', async () => {
const root = tmp();
mkdirSync(join(root, 'old'));
vi.spyOn(fsp, 'rename').mockRejectedValue(Object.assign(new Error('perm'), { code: 'EPERM' }));
const err = await moveCaseFolder(join(root, 'old'), join(root, 'new'), 2, 1).catch((e) => e);
expect(err).toBeInstanceOf(CaseRenameError);
expect(err.code).toBe('LOCKED');
expect(err.message).toMatch(/in use by another program/);
expect(existsSync(join(root, 'old'))).toBe(true);
});
it('refuses to cross filesystems', async () => {
const root = tmp();
mkdirSync(join(root, 'old'));
vi.spyOn(fsp, 'rename').mockRejectedValue(Object.assign(new Error('xdev'), { code: 'EXDEV' }));
await expect(moveCaseFolder(join(root, 'old'), join(root, 'new'))).rejects.toMatchObject({ code: 'CROSS_DEVICE' });
});
});
describe('Claude history', () => {
it('names a project directory the way Claude Code does', () => {
expect(claudeProjectDirName('/home/devvyn/codeman-cases/my_case')).toBe('-home-devvyn-codeman-cases-my-case');
expect(claudeProjectDirName('/home/devvyn/.dotcase')).toBe('-home-devvyn--dotcase');
});
it('moves the old path’s history to the new one', async () => {
const cfg = tmp();
const projects = join(cfg, 'projects');
const oldPath = join(tmp(), 'alpha');
const newPath = join(tmp(), 'beta');
mkdirSync(join(projects, claudeProjectDirName(oldPath)), { recursive: true });
writeFileSync(join(projects, claudeProjectDirName(oldPath), 'c.jsonl'), '{}');
expect(await moveClaudeHistory(oldPath, newPath, cfg)).toBe(true);
expect(existsSync(join(projects, claudeProjectDirName(newPath), 'c.jsonl'))).toBe(true);
expect(existsSync(join(projects, claudeProjectDirName(oldPath)))).toBe(false);
expect(await moveClaudeHistory(join(tmp(), 'none'), newPath, cfg)).toBe(false);
});
it('merges into history already at the new path (a deleted case’s leftovers) without overwriting any of it', async () => {
const cfg = tmp();
const projects = join(cfg, 'projects');
const oldPath = join(tmp(), 'alpha');
const newPath = join(tmp(), 'beta');
const from = join(projects, claudeProjectDirName(oldPath));
const to = join(projects, claudeProjectDirName(newPath));
mkdirSync(join(from, 'conv-a', 'subagents'), { recursive: true });
writeFileSync(join(from, 'conv-a.jsonl'), 'alpha');
writeFileSync(join(from, 'conv-a', 'subagents', 'agent-1.jsonl'), 'alpha agent');
mkdirSync(join(from, 'memory'));
writeFileSync(join(from, 'memory', 'MEMORY.md'), 'alpha memory');
// Claude Code never removes a project directory, so a case deleted earlier under the new name left this.
mkdirSync(join(to, 'memory'), { recursive: true });
writeFileSync(join(to, 'conv-old.jsonl'), 'old beta');
writeFileSync(join(to, 'memory', 'MEMORY.md'), 'old beta memory');
expect(await moveClaudeHistory(oldPath, newPath, cfg)).toBe(true);
expect(readFileSync(join(to, 'conv-a.jsonl'), 'utf8')).toBe('alpha');
expect(readFileSync(join(to, 'conv-a', 'subagents', 'agent-1.jsonl'), 'utf8')).toBe('alpha agent');
expect(readFileSync(join(to, 'conv-old.jsonl'), 'utf8')).toBe('old beta');
// an entry both have is never overwritten: it stays where it was
expect(readFileSync(join(to, 'memory', 'MEMORY.md'), 'utf8')).toBe('old beta memory');
expect(readFileSync(join(from, 'memory', 'MEMORY.md'), 'utf8')).toBe('alpha memory');
expect(existsSync(join(from, 'conv-a.jsonl'))).toBe(false);
});
it('removes the old project directory once a merge has emptied it, and never merges into a symlink', async () => {
const cfg = tmp();
const projects = join(cfg, 'projects');
const oldPath = join(tmp(), 'alpha');
const newPath = join(tmp(), 'beta');
const from = join(projects, claudeProjectDirName(oldPath));
const to = join(projects, claudeProjectDirName(newPath));
mkdirSync(from, { recursive: true });
mkdirSync(to, { recursive: true });
writeFileSync(join(from, 'conv-a.jsonl'), 'alpha');
expect(await moveClaudeHistory(oldPath, newPath, cfg)).toBe(true);
expect(existsSync(from)).toBe(false);
const elsewhere = tmp();
const linkedPath = join(tmp(), 'gamma');
mkdirSync(from);
writeFileSync(join(from, 'conv-b.jsonl'), 'alpha');
symlinkSync(elsewhere, join(projects, claudeProjectDirName(linkedPath)));
expect(await moveClaudeHistory(oldPath, linkedPath, cfg)).toBe(false);
expect(existsSync(join(from, 'conv-b.jsonl'))).toBe(true);
expect(existsSync(join(elsewhere, 'conv-b.jsonl'))).toBe(false);
});
});
describe('small helpers', () => {
it('renames in an order list keeping the position, and says nothing when absent', () => {
expect(renameInOrder(['a', 'b', 'c'], 'b', 'z')).toEqual(['a', 'z', 'c']);
expect(renameInOrder(['a'], 'q', 'z')).toBeNull();
expect(renameInOrder(undefined, 'a', 'z')).toBeNull();
});
it('puts the new name next to the old folder', () => {
expect(siblingPath('/srv/work/app', 'app2')).toBe('/srv/work/app2');
});
});
+322
View File
@@ -0,0 +1,322 @@
/**
* @fileoverview POST /api/cases/:name/rename: a local case moves with its name, a linked case renames its
* alias (and, on request, its real folder), live sessions are listed and only stopped on request, and the
* registries (caseOrder, lastUsedCase, linked-cases.json) and Claude's history follow. Real filesystem under
* test/setup.ts's temp HOME; every folder is removed through safeRmHomeTree. Port: N/A (app.inject()).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import fs from 'node:fs/promises';
import { homedir } from 'node:os';
import { basename, dirname, join } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
import { dataPath } from '../../src/config/instance.js';
import { SETTINGS_PATH, resolveCasesDir } from '../../src/web/route-helpers.js';
import { claudeProjectDirName } from '../../src/case-rename.js';
import { safeRmHomeTree } from '../mocks/index.js';
const LINKED = () => dataPath('linked-cases.json');
const CASES_DIR = () => join(homedir(), 'codeman-cases');
const sandboxed = () => basename(homedir()).startsWith('codeman-vitest-');
let work = '';
let claudeCfg = '';
const made: string[] = [];
const linked = (): Record<string, string> => (existsSync(LINKED()) ? JSON.parse(readFileSync(LINKED(), 'utf8')) : {});
const settings = (): Record<string, unknown> => JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'));
const rename = (app: Awaited<ReturnType<typeof createRouteTestHarness>>['app'], name: string, payload: object) =>
app.inject({ method: 'POST', url: `/api/cases/${name}/rename`, payload });
const localCase = (name: string) => {
const dir = join(CASES_DIR(), name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'CLAUDE.md'), name);
made.push(dir);
return dir;
};
beforeEach(() => {
if (!sandboxed()) throw new Error('case-rename-routes.test.ts renames folders: run it via npm test');
safeRmHomeTree(LINKED());
work = realpathSync(mkdtempSync(join(homedir(), 'case-rename-')));
claudeCfg = join(work, 'claude-config');
process.env.CLAUDE_CONFIG_DIR = claudeCfg;
mkdirSync(join(homedir(), '.codeman'), { recursive: true });
writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['keep', 'alpha', 'tail'], lastUsedCase: 'alpha' }));
});
afterEach(() => {
delete process.env.CLAUDE_CONFIG_DIR;
delete process.env.CODEMAN_MULTIUSER;
for (const dir of made.splice(0)) safeRmHomeTree(dir);
if (work) safeRmHomeTree(work);
work = '';
if (sandboxed()) safeRmHomeTree(LINKED());
});
describe('POST /api/cases/:name/rename (a case under the cases directory)', () => {
it('moves the folder, keeps its place in the order, follows lastUsedCase and carries Claude history', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const oldDir = localCase('alpha');
mkdirSync(join(claudeCfg, 'projects', claudeProjectDirName(oldDir)), { recursive: true });
writeFileSync(join(claudeCfg, 'projects', claudeProjectDirName(oldDir), 'c.jsonl'), '{}');
const res = await rename(app, 'alpha', { newName: 'beta' });
expect(res.statusCode).toBe(200);
const newDir = join(CASES_DIR(), 'beta');
made.push(newDir);
expect(res.json().data).toMatchObject({ oldName: 'alpha', newName: 'beta', newPath: newDir, folderMoved: true });
expect(existsSync(oldDir)).toBe(false);
expect(readFileSync(join(newDir, 'CLAUDE.md'), 'utf8')).toBe('alpha');
expect(settings()).toMatchObject({ caseOrder: ['keep', 'beta', 'tail'], lastUsedCase: 'beta' });
expect(existsSync(join(claudeCfg, 'projects', claudeProjectDirName(newDir), 'c.jsonl'))).toBe(true);
});
it('carries Claude history into the project directory a deleted case of the new name left behind', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const oldDir = localCase('alpha');
const newDir = join(CASES_DIR(), 'beta');
const oldKey = join(claudeCfg, 'projects', claudeProjectDirName(oldDir));
const newKey = join(claudeCfg, 'projects', claudeProjectDirName(newDir));
mkdirSync(oldKey, { recursive: true });
writeFileSync(join(oldKey, 'conv-alpha.jsonl'), '{}');
// case delete never removes Claude Code's history, so an earlier "beta" left its project directory
mkdirSync(newKey, { recursive: true });
writeFileSync(join(newKey, 'conv-old-beta.jsonl'), '{}');
const res = await rename(app, 'alpha', { newName: 'beta' });
made.push(newDir);
expect(res.statusCode).toBe(200);
expect(existsSync(join(newKey, 'conv-alpha.jsonl'))).toBe(true);
expect(existsSync(join(newKey, 'conv-old-beta.jsonl'))).toBe(true);
expect(existsSync(oldKey)).toBe(false);
});
it('refuses a dangling symlink at the new folder before it stops any session', async () => {
const { app, ctx } = await createRouteTestHarness(registerCaseRoutes);
const oldDir = localCase('alpha');
const target = join(CASES_DIR(), 'beta');
symlinkSync(join(work, 'nowhere'), target);
made.push(target);
(ctx.sessions as Map<string, unknown>).set('s1', { id: 's1', name: 'w1', workingDir: oldDir });
const res = await rename(app, 'alpha', { newName: 'beta', stopSessions: true });
expect(res.statusCode).toBe(409);
expect(res.json().errorCode).toBe('ALREADY_EXISTS');
expect(ctx.cleanupSession).not.toHaveBeenCalled();
expect(existsSync(oldDir)).toBe(true);
});
it('refuses a name that is taken (local or linked), the same name, or one that is not allowed', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
localCase('alpha');
localCase('taken');
const target = join(work, 'elsewhere');
mkdirSync(target);
writeFileSync(LINKED(), JSON.stringify({ lk: target }));
expect((await rename(app, 'alpha', { newName: 'taken' })).statusCode).toBe(409);
expect((await rename(app, 'alpha', { newName: 'lk' })).statusCode).toBe(409);
expect((await rename(app, 'alpha', { newName: 'alpha' })).statusCode).toBe(400);
expect((await rename(app, 'alpha', { newName: '../evil' })).statusCode).toBe(400);
expect((await rename(app, 'nope', { newName: 'whatever' })).statusCode).toBe(404);
expect(existsSync(join(CASES_DIR(), 'alpha'))).toBe(true);
});
it('lists the live sessions that have the folder open and does not touch it until told to stop them', async () => {
const { app, ctx } = await createRouteTestHarness(registerCaseRoutes);
const oldDir = localCase('alpha');
(ctx.sessions as Map<string, unknown>).set('s-live', {
id: 's-live',
name: 'w1-alpha',
workingDir: join(oldDir, 'sub'),
});
const refused = await rename(app, 'alpha', { newName: 'beta' });
expect(refused.statusCode).toBe(409);
expect(refused.json()).toMatchObject({ requiresStop: true, sessions: [{ id: 's-live', name: 'w1-alpha' }] });
expect(existsSync(oldDir)).toBe(true);
expect(ctx.cleanupSession).not.toHaveBeenCalled();
const done = await rename(app, 'alpha', { newName: 'beta', stopSessions: true });
made.push(join(CASES_DIR(), 'beta'));
expect(done.statusCode).toBe(200);
expect(done.json().data.stoppedSessions).toBe(1);
expect(ctx.cleanupSession).toHaveBeenCalledWith('s-live', true, 'case_rename');
expect(existsSync(join(CASES_DIR(), 'beta'))).toBe(true);
});
it('does not stop a remote session whose remote path equals the local folder', async () => {
const { app, ctx } = await createRouteTestHarness(registerCaseRoutes);
const dir = localCase('alpha');
(ctx.sessions as Map<string, unknown>).set('s-remote', { id: 's-remote', name: 'r', workingDir: dir, remote: {} });
const res = await rename(app, 'alpha', { newName: 'beta' });
made.push(join(CASES_DIR(), 'beta'));
expect(res.statusCode).toBe(200);
expect(ctx.cleanupSession).not.toHaveBeenCalled();
});
it("carries the owner's default session group to the new name", async () => {
const { app, ctx } = await createRouteTestHarness(registerCaseRoutes);
localCase('alpha');
vi.mocked(ctx.tabLayouts.caseDefaultGroup).mockResolvedValueOnce({ groupId: 'g1', groups: [] });
const res = await rename(app, 'alpha', { newName: 'beta' });
made.push(join(CASES_DIR(), 'beta'));
expect(res.statusCode).toBe(200);
expect(ctx.tabLayouts.setCaseDefaultGroup).toHaveBeenCalledWith(expect.any(String), 'beta', 'g1');
expect(ctx.tabLayouts.forgetCaseDefaultGroup).toHaveBeenCalledWith(expect.any(String), 'alpha');
});
it('answers a folder that appears at the target with ALREADY_EXISTS and keeps renameCode', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
localCase('alpha');
localCase('beta');
const res = await rename(app, 'alpha', { newName: 'beta' });
expect(res.statusCode).toBe(409);
expect(res.json().errorCode).toBe('ALREADY_EXISTS');
});
it('does not touch a case whose name merely starts with the same letters', async () => {
const { app, ctx } = await createRouteTestHarness(registerCaseRoutes);
localCase('alpha');
const sibling = localCase('alpha2');
(ctx.sessions as Map<string, unknown>).set('s-other', { id: 's-other', name: 'x', workingDir: sibling });
const res = await rename(app, 'alpha', { newName: 'beta' });
made.push(join(CASES_DIR(), 'beta'));
expect(res.statusCode).toBe(200);
expect(ctx.cleanupSession).not.toHaveBeenCalled();
});
});
describe('POST /api/cases/:name/rename (a linked case)', () => {
it('renames only the alias by default: the real folder and its sessions are left alone', async () => {
const { app, ctx } = await createRouteTestHarness(registerCaseRoutes);
const real = join(work, 'my-project');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real, other: join(work, 'o') }));
(ctx.sessions as Map<string, unknown>).set('s1', { id: 's1', name: 'w1', workingDir: real });
const res = await rename(app, 'proj', { newName: 'renamed' });
expect(res.statusCode).toBe(200);
expect(res.json().data).toMatchObject({ folderMoved: false, newPath: real, stoppedSessions: 0 });
expect(linked()).toEqual({ other: join(work, 'o'), renamed: real });
expect(existsSync(real)).toBe(true);
expect(ctx.cleanupSession).not.toHaveBeenCalled();
});
it('renames the real folder too, next to the old one, when asked', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const real = join(work, 'my-project');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
expect(res.statusCode).toBe(200);
expect(existsSync(real)).toBe(false);
expect(existsSync(join(work, 'renamed'))).toBe(true);
expect(linked()).toEqual({ renamed: join(work, 'renamed') });
});
it('puts the folder and settings.json back when the registry cannot be written', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const real = join(work, 'my-project');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' }));
// the registry reads fine but cannot be written: the rename must undo the folder move
chmodSync(LINKED(), 0o444);
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
expect(res.statusCode).toBe(500);
expect(existsSync(real)).toBe(true);
expect(linked()).toEqual({ proj: real });
expect(existsSync(join(work, 'renamed'))).toBe(false);
// settings.json is written first, so it must be put back too
expect(settings()).toEqual({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' });
});
it('says where the folder is when it cannot be moved back', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const real = join(work, 'my-project');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
chmodSync(LINKED(), 0o444);
const moved = join(work, 'renamed');
const realRename = fs.rename;
const spy = vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => {
if (from === moved) throw Object.assign(new Error('resource busy'), { code: 'EBUSY' });
return realRename(from, to);
});
try {
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
expect(res.statusCode).toBe(500);
expect(res.json().error).toContain(`is now at ${moved}`);
expect(existsSync(moved)).toBe(true);
} finally {
spy.mockRestore();
}
});
it('reports the sessions it stopped when the rename fails after stopping them', async () => {
const { app, ctx } = await createRouteTestHarness(registerCaseRoutes);
const real = join(work, 'my-project');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
chmodSync(LINKED(), 0o444);
(ctx.sessions as Map<string, unknown>).set('s1', { id: 's1', name: 'w1', workingDir: real });
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true, stopSessions: true });
expect(res.statusCode).toBe(500);
expect(res.json().stoppedSessions).toBe(1);
expect(ctx.cleanupSession).toHaveBeenCalledWith('s1', true, 'case_rename');
expect(existsSync(real)).toBe(true);
});
it('puts the folder and the registry back when settings.json cannot be written', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const real = join(work, 'my-project');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real, other: join(work, 'x') }));
writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['proj'] }));
chmodSync(SETTINGS_PATH, 0o444);
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
chmodSync(SETTINGS_PATH, 0o644);
expect(res.statusCode).toBe(500);
expect(existsSync(real)).toBe(true);
expect(linked()).toEqual({ proj: real, other: join(work, 'x') });
});
it('answers 404 for a linked case a non-admin cannot see in multi-user mode', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'bob', role: 'user' } });
const real = join(work, 'p');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
const res = await rename(app, 'proj', { newName: 'renamed' });
expect(res.statusCode).toBe(404);
expect(linked()).toEqual({ proj: real });
});
it('leaves the server-wide settings.json alone when a non-admin renames their own case', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'bob', role: 'user' } });
const bobsDir = join(resolveCasesDir({ username: 'bob', role: 'user' } as never), 'alpha');
mkdirSync(bobsDir, { recursive: true });
made.push(dirname(bobsDir));
const before = readFileSync(SETTINGS_PATH, 'utf8');
const res = await rename(app, 'alpha', { newName: 'bobs' });
expect(res.statusCode).toBe(200);
expect(existsSync(join(dirname(bobsDir), 'bobs'))).toBe(true);
expect(readFileSync(SETTINGS_PATH, 'utf8')).toBe(before);
});
});
+20
View File
@@ -2613,6 +2613,26 @@ describe('session-routes', () => {
expect(row.workingDir).not.toBe(home);
});
it("resumes a renamed case's conversation in the new folder, not the stale cwd in the transcript", async () => {
const home = process.env.HOME as string;
const realDir = join(home, 'renamed-case');
await mkdir(realDir, { recursive: true });
const projectKey = realDir.replace(/[^a-zA-Z0-9]/g, '-');
const projDir = join(home, '.claude', 'projects', projectKey);
await mkdir(projDir, { recursive: true });
const sessionId = '33333333-3333-3333-3333-333333333333';
const line =
JSON.stringify({ type: 'user', cwd: join(home, 'old-case-name'), message: { role: 'user', content: 'hi' } }) +
'\n';
await writeFile(join(projDir, `${sessionId}.jsonl`), line + '#'.repeat(4200 - line.length));
const res = await harness.app.inject({ method: 'GET', url: `/api/history/sessions?projectKey=${projectKey}` });
const row = JSON.parse(res.body).data.sessions.find((s: { sessionId: string }) => s.sessionId === sessionId);
expect(row.workingDir).toBe(realDir);
});
it('prefers the dotdir over a same-named non-dot sibling, and never emits a "//" path', async () => {
// A doubled dash also lets the decoder read the empty split segment as a
// directory NAME. `isDir(current + '/' + '')` stats `current + '/'`, which