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

+110
View File
@@ -0,0 +1,110 @@
/**
* @fileoverview The Rename button's flow in session-ui.js (renameCase): the prompts, the second confirmation
* before sessions are stopped, and the follow-up refresh. Evaluates the real file against a stub app.
*/
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');
function makeApp(opts: { prompt: string | null; confirms: boolean[]; responses: unknown[]; cases: unknown[] }) {
const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
const confirmMessages: string[] = [];
const toasts: string[] = [];
const store = new Map<string, string>([['caseSettings_alpha', '{"agentTeams":true}']]);
const queue = [...opts.responses];
function CodemanApp(this: unknown) {}
const ctx = vm.createContext({
CodemanApp,
window: {},
document: { getElementById: () => ({ value: 'alpha' }) },
prompt: () => opts.prompt,
confirm: (m: string) => (confirmMessages.push(m), opts.confirms.shift() ?? false),
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, calls, confirmMessages, toasts, store, refreshed };
}
const ok = { success: true, data: {} };
describe('renameCase (case manager)', () => {
it('renames a local case, moves its run options and reselects it under the new name', async () => {
const t = makeApp({
prompt: ' beta ',
confirms: [],
responses: [ok],
cases: [{ name: 'alpha', location: 'local' }],
});
await t.app.renameCase('alpha');
expect(t.calls).toEqual([{ url: '/api/cases/alpha/rename', body: { newName: 'beta' } }]);
expect(t.store.get('caseSettings_beta')).toBe('{"agentTeams":true}');
expect(t.store.has('caseSettings_alpha')).toBe(false);
expect(t.refreshed).toEqual(['beta', 'render']);
});
it('does nothing for a cancelled prompt, the same name, or a name that is not allowed', async () => {
for (const entered of [null, '', 'alpha', 'a b', '../x']) {
const t = makeApp({ prompt: entered, confirms: [], responses: [], cases: [{ name: 'alpha' }] });
await t.app.renameCase('alpha');
expect(t.calls, String(entered)).toEqual([]);
}
});
it('asks a linked case whether to rename the real folder, and sends the answer', async () => {
const t = makeApp({
prompt: 'beta',
confirms: [true],
responses: [ok],
cases: [{ name: 'alpha', linked: true, path: '/srv/app' }],
});
await t.app.renameCase('alpha');
expect(t.confirmMessages[0]).toContain('/srv/app');
expect(t.calls[0].body).toEqual({ newName: 'beta', renameFolder: true });
});
it('lists the live sessions and only stops them after a second confirmation', async () => {
const refusal = {
success: false,
error: '1 live session has this folder open.',
requiresStop: true,
sessions: [{ id: 's', name: 'w1-alpha' }],
};
const t = makeApp({ prompt: 'beta', confirms: [true], responses: [refusal, ok], cases: [{ name: 'alpha' }] });
await t.app.renameCase('alpha');
expect(t.confirmMessages[0]).toContain('w1-alpha');
expect(t.calls.map((c) => c.body)).toEqual([{ newName: 'beta' }, { newName: 'beta', stopSessions: true }]);
const declined = makeApp({ prompt: 'beta', confirms: [false], responses: [refusal], cases: [{ name: 'alpha' }] });
await declined.app.renameCase('alpha');
expect(declined.calls).toHaveLength(1);
expect(declined.refreshed).toEqual([]);
});
it('shows the server’s message 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({ prompt: 'beta', confirms: [], responses: [locked], cases: [{ name: 'alpha' }] });
await t.app.renameCase('alpha');
expect(t.toasts).toEqual(['The folder is in use by another program (EBUSY).']);
expect(t.refreshed).toEqual([]);
expect(t.store.has('caseSettings_alpha')).toBe(true);
});
});
+165
View File
@@ -0,0 +1,165 @@
/**
* @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, 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, and leaves existing history alone', 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);
mkdirSync(join(projects, claudeProjectDirName(oldPath)), { recursive: true });
expect(await moveClaudeHistory(oldPath, newPath, cfg)).toBe(false); // the target already has history
expect(existsSync(join(projects, claudeProjectDirName(oldPath)))).toBe(true);
expect(await moveClaudeHistory(join(tmp(), 'none'), newPath, cfg)).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');
});
});
+179
View File
@@ -0,0 +1,179 @@
/**
* @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 } from 'vitest';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { basename, 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 } 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('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 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 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 }));
// 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);
});
it('is admin only 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).not.toBe(200);
expect(linked()).toEqual({ proj: real });
});
});