mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 10:59:32 +01:00
Merge pull request #680: feat(cases): rename a case and its folder, with a lock and live-session handling
This commit is contained in:
commit
2fdbe786b3
18 files changed
+1454
-45
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user