From d1e0e0aea6ae90c96130bffdbb9d036b1b36a6e3 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sat, 24 Oct 2026 14:43:13 +0200 Subject: [PATCH] fix: address the release-prep review of #680 Claude history merges, never overwriting, into a project directory a deleted case of the new name left behind, instead of staying orphaned under the old key. The free-target pre-check also refuses a dangling symlink (lstat), so stopSessions no longer ends sessions before a 409 EXISTS. The rename dialog says stopped sessions are closed (their tabs go away) and, after a failed rename, how many were already closed. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 2 +- docs/api-reference.md | 2 +- docs/wiki/Core-Concepts.md | 10 +++-- src/case-rename.ts | 58 ++++++++++++++++--------- src/web/public/i18n.js | 4 +- src/web/public/session-ui.js | 9 +++- src/web/routes/case-routes.ts | 16 ++++++- test/case-rename-ui.test.ts | 16 +++++++ test/case-rename.test.ts | 59 +++++++++++++++++++++++--- test/routes/case-rename-routes.test.ts | 48 ++++++++++++++++++++- 10 files changed, 185 insertions(+), 39 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 83f77c69..5454550b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -181,7 +181,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **DeepSeek web UI** (`POST`/`GET`/`DELETE /api/deepseek/web`, `deepseek-web-server.ts`): the Run menu's "DeepSeek web UI..." entry supervises ONE background `dsh web` child process, deliberately **NOT a shell session**. ⚠️ Every piece is load-bearing: exactly one server (a second click REUSES it), restarted when the browser authority changes (`--trusted-host`, last asker wins), killed on shutdown via `stopDeepSeekWeb()` (the detached child would otherwise outlive Codeman and hold its port), and failures returned to the caller. ⚠️ Never hardcode the port: search from 3080 across 40, detect free ports by BINDING, then wait for the server to really answer. ⚠️ Both `POST` and `DELETE` must stay behind `canUsernameRunPrivilegedCommands` (booting a profile runs its plugin code; the server is shared). → [architecture-invariants#deepseek-web-ui](docs/architecture-invariants.md#deepseek-web-ui) -**Case rename** (`POST /api/cases/:name/rename`, `src/case-rename.ts`; `docs/wiki/Core-Concepts.md#case`): a case under the cases dir is its folder name and always moves; a linked case renames its alias and moves its real folder only with `renameFolder`; remote and Docker cases are refused. ⚠️ A folder that sessions have open is the hazard: live sessions at or under it make the route answer `409 requiresStop` with their names, and only `stopSessions: true` ends them (via `ctx.cleanupSession`, nothing is restarted). `withCaseLock` holds the old and new paths for the whole operation (`isCasePathLocked`; the `Session` constructor throws for a working directory inside a held path, so nothing starts mid-move), `moveCaseFolder` refuses an existing target itself (POSIX `rename` would silently replace an EMPTY directory) and retries `EBUSY`/`EPERM`/`EACCES` before answering `423 LOCKED`. If the registries (`linked-cases.json`, `caseOrder`, `lastUsedCase`) fail after the move, `settings.json` and the folder are put back (a failed move-back says the folder is now at the new path), and an error after `stopSessions` carries `stoppedSessions`. Claude Code keys history by path (`claudeProjectDirName`: every non-alphanumeric becomes `-`), so `/projects/` is renamed too; other CLIs' history, Docker bind mounts, `git worktree` links, cron jobs' `workingDir`, Claude history of subfolder sessions and Claude Code's per-project entry in `~/.claude.json` (local-scope `mcpServers`, `allowedTools`, trust; writing another CLI's config is the opt-in MCP sync's job) are NOT migrated. The history scanner prefers the decoded project key over a transcript's `cwd` that no longer exists, so Resume follows a rename. Only an admin's rename touches the server-wide `settings.json` (`caseOrder`, `lastUsedCase`); the owner's default session group is carried across with `TabLayoutService`. `settings.json` is written before `linked-cases.json`, and both are restored if the registry write fails. +**Case rename** (`POST /api/cases/:name/rename`, `src/case-rename.ts`; `docs/wiki/Core-Concepts.md#case`): a case under the cases dir is its folder name and always moves; a linked case renames its alias and moves its real folder only with `renameFolder`; remote and Docker cases are refused. ⚠️ A folder that sessions have open is the hazard: live sessions at or under it make the route answer `409 requiresStop` with their names, and only `stopSessions: true` ends them (via `ctx.cleanupSession`: closed like a tab close, uploads included, nothing is restarted); the free-target pre-check runs before that and refuses a dangling symlink too (`lstat`, as `moveCaseFolder` does), so no session is ended for a move that cannot happen. `withCaseLock` holds the old and new paths for the whole operation (`isCasePathLocked`; the `Session` constructor throws for a working directory inside a held path, so nothing starts mid-move), `moveCaseFolder` refuses an existing target itself (POSIX `rename` would silently replace an EMPTY directory) and retries `EBUSY`/`EPERM`/`EACCES` before answering `423 LOCKED`. If the registries (`linked-cases.json`, `caseOrder`, `lastUsedCase`) fail after the move, `settings.json` and the folder are put back (a failed move-back says the folder is now at the new path), and an error after `stopSessions` carries `stoppedSessions`. Claude Code keys history by path (`claudeProjectDirName`: every non-alphanumeric becomes `-`), so `/projects/` is renamed too, or merged entry by entry, never overwriting, into one already at the new key (case delete never removes history, so an earlier case of the new name leaves one); other CLIs' history, Docker bind mounts, `git worktree` links, cron jobs' `workingDir`, Claude history of subfolder sessions and Claude Code's per-project entry in `~/.claude.json` (local-scope `mcpServers`, `allowedTools`, trust; writing another CLI's config is the opt-in MCP sync's job) are NOT migrated. The history scanner prefers the decoded project key over a transcript's `cwd` that no longer exists, so Resume follows a rename. Only an admin's rename touches the server-wide `settings.json` (`caseOrder`, `lastUsedCase`); the owner's default session group is carried across with `TabLayoutService`. `settings.json` is written before `linked-cases.json`, and both are restored if the registry write fails. **Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`): points a session at a user-configured OpenAI-compatible endpoint (store `custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`, 0600). `authStyle` is `bearer` or `api-key`, never both headers (hangs the server). The per-CLI redirect is registry data, `capabilities.customModelInjection` (`env` / `configContentEnv` / `configDir` / `unsupported`), computed by the pure `custom-model-injection.ts`; ⚠️ `configDir` writes an isolated per-session config, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config. ⚠️ Claude applies via `Session.restartCli()` (a relaunch in the existing pane, so the conversation is pinned as `resumeSessionId`), the rest one-shot at launch; retired env keys must also be `setenv -u`'d (`_pendingEnvUnsets`), since tmux env survives `respawn-pane`. ⚠️ Remote/Docker sessions are refused (400). ⚠️ Persist only KEYS (`__customModel`), never values (they carry the API key). ⚠️ Every redirectable var must be in that CLI's `privilegedEnvKeys`, and `ANTHROPIC_*` stays out of claude's `allowedPrefixes`. ⚠️ The Run-menu picker builds entries from `window.__codemanCustomModelClis` (escaped via `escapeScriptJson()`), never a hardcoded CLI id list, and launches through `run()` via a temporary `_runMode` swap, never `setRunMode()`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles) diff --git a/docs/api-reference.md b/docs/api-reference.md index 01f3a3a2..e4da1ddc 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1135,7 +1135,7 @@ A local case whose folder is the top of a git repository can keep **worktrees** A non-admin's `POST /api/quick-start` and `POST /api/ralph-loop/start` work as before but do not record their case as `lastUsedCase` (a server-wide key). -`POST /api/cases/:name/rename` renames a case. A case under the cases directory is its folder, so it always moves; a linked case renames its alias in `linked-cases.json` and moves its real folder (next to the old one, named `newName`) only with `renameFolder: true`. Remote and Docker cases are `400`. The new name must be free in every namespace the list merges (`409`). Live sessions whose working directory is at or inside the folder that would move make it `409` with `requiresStop: true` and `sessions: [{ id, name }]`; retry with `stopSessions: true` to end them first (nothing is restarted); another user's session is `403`. The folder is held by an in-process lock for the whole operation (an overlapping rename is `409 BUSY`, and `new Session` refuses a working directory inside it), the move is retried for a transient `EBUSY`/`EPERM`/`EACCES` and then answers `423` with `renameCode: "LOCKED"`, a cross-device move is `400`, and if the registries cannot be written afterwards `settings.json` and the folder are put back (when the folder cannot be moved back, the `500` says it is now at the new path). An error from the rename itself carries `stoppedSessions`: how many sessions it ended before it failed. On success the response is `{ oldName, newName, oldPath, newPath, folderMoved, stoppedSessions }`; `caseOrder`, `lastUsedCase`, `linked-cases.json` and Claude Code's `projects/` history are updated, and `case:renamed` is broadcast. A linked case is admin only in multi-user mode. +`POST /api/cases/:name/rename` renames a case. A case under the cases directory is its folder, so it always moves; a linked case renames its alias in `linked-cases.json` and moves its real folder (next to the old one, named `newName`) only with `renameFolder: true`. Remote and Docker cases are `400`. The new name must be free in every namespace the list merges (`409`). Live sessions whose working directory is at or inside the folder that would move make it `409` with `requiresStop: true` and `sessions: [{ id, name }]`; retry with `stopSessions: true` to end them first (they are closed as by `DELETE /api/sessions/:id`, nothing is restarted); another user's session is `403`. The folder is held by an in-process lock for the whole operation (an overlapping rename is `409 BUSY`, and `new Session` refuses a working directory inside it), the move is retried for a transient `EBUSY`/`EPERM`/`EACCES` and then answers `423` with `renameCode: "LOCKED"`, a cross-device move is `400`, and if the registries cannot be written afterwards `settings.json` and the folder are put back (when the folder cannot be moved back, the `500` says it is now at the new path). An error from the rename itself carries `stoppedSessions`: how many sessions it ended before it failed. On success the response is `{ oldName, newName, oldPath, newPath, folderMoved, stoppedSessions }`; `caseOrder`, `lastUsedCase`, `linked-cases.json` and Claude Code's `projects/` history are updated (history already at the new path is merged into, never overwritten), and `case:renamed` is broadcast. A linked case is admin only in multi-user mode. ### System operations diff --git a/docs/wiki/Core-Concepts.md b/docs/wiki/Core-Concepts.md index f71f9758..c9a134f1 100644 --- a/docs/wiki/Core-Concepts.md +++ b/docs/wiki/Core-Concepts.md @@ -38,13 +38,17 @@ folder on disk** in the rename dialog: then its real folder is renamed too, next Remote and Docker cases cannot be renamed. Renaming does the following for you: - **Sessions are never moved from under you.** If live sessions have the folder open, the rename is - refused and lists them; you can then choose to stop them (nothing is restarted). While a rename runs, - Codeman holds the folder so no session can start inside it half-moved. + refused and lists them; you can then choose to stop them. That closes them the way closing their tabs + does (nothing is restarted, and the last one to close removes the files attached to prompts in the + case's `.codeman-uploads`). While a rename runs, Codeman holds the folder so no session can start + inside it half-moved. - **A folder another program holds open is reported, not forced.** On Windows, SMB shares and some FUSE mounts a directory with an open handle cannot be renamed; Codeman retries briefly, then says the folder is in use and to close whatever has it (a terminal, an editor, a file browser, a sync client). - **Claude Code's history follows.** It keys conversations by the folder's path, so the rename moves - `/projects/` to the new path and `--resume` still finds them. Other CLIs' + `/projects/` to the new path and `--resume` still finds them. When the new + path already has history (a case deleted earlier under that name leaves its history behind), the + conversations are merged into it without overwriting anything. Other CLIs' history, Docker mounts and `git worktree` links that point at the old path are not updated. The Resume list uses the renamed folder too, and the case's default session group follows the new name. - **Not migrated.** Scheduled (cron) jobs keep the old `workingDir` and fail at their next run until you diff --git a/src/case-rename.ts b/src/case-rename.ts index 70b9399c..82a34bda 100644 --- a/src/case-rename.ts +++ b/src/case-rename.ts @@ -131,34 +131,50 @@ export function claudeProjectDirName(path: string): string { /** * Move Claude Code's history for the old folder to the new one, so `--resume` and the past-sessions list - * still find the conversations. Leaves it alone when the new path already has history (nothing is merged - * or overwritten) or when there is none. Returns whether anything moved. + * still find the conversations. Returns whether anything moved. + * + * The new path can already have history: Claude Code never removes a project directory, and deleting a + * case does not either, so a case deleted earlier under the new name leaves one behind. The old history is + * then merged into it entry by entry (conversations are UUID-named, so they do not collide), never + * overwriting: an entry both directories have (a `memory/` folder, say) stays at the old path. A merge + * only goes between two real directories, never through a symlink. */ export async function moveClaudeHistory(oldPath: string, newPath: string, configDir?: string): Promise { const projects = claudeProjectsDir(configDir); const from = join(projects, claudeProjectDirName(oldPath)); const to = join(projects, claudeProjectDirName(newPath)); if (from === to) return false; - if ( - !(await fs.lstat(from).then( - () => true, - () => false - )) - ) - return false; - if ( - await fs.lstat(to).then( - () => true, - () => false - ) - ) - return false; - try { - await fs.rename(from, to); - return true; - } catch { - return false; // history is a convenience; the folder move already succeeded + const fromStat = await fs.lstat(from).catch(() => null); + if (!fromStat) return false; + const toStat = await fs.lstat(to).catch(() => null); + if (!toStat) { + try { + await fs.rename(from, to); + return true; + } catch { + return false; // history is a convenience; the folder move already succeeded + } } + if (!fromStat.isDirectory() || !toStat.isDirectory()) return false; + let moved = false; + for (const entry of await fs.readdir(from).catch(() => [] as string[])) { + const target = join(to, entry); + if ( + await fs.lstat(target).then( + () => true, + () => false + ) + ) + continue; + try { + await fs.rename(join(from, entry), target); + moved = true; + } catch { + /* left at the old path, like an entry both have */ + } + } + await fs.rmdir(from).catch(() => {}); // succeeds only once nothing was left behind + return moved; } /** Replace `from` by `to` in a list, keeping its position (the case order). */ diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 7b16e451..b8ff0578 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -1346,8 +1346,8 @@ [/^Case renamed to "(.+)"$/, (_m, name) => `案例已重命名为“${name}”`], [/^New name for "(.+)"$/, (_m, name) => `“${name}”的新名称`], [ - /^These sessions have the folder open: (.+)\. They will be stopped \(nothing is restarted\) before the folder is renamed\.$/, - (_m, list) => `这些会话正在使用该文件夹:${list}。重命名前将停止它们(不会重新启动)。`, + /^These sessions have the folder open: (.+)\. They will be closed \(their tabs go away and nothing is restarted\) before the folder is renamed\.$/, + (_m, list) => `这些会话正在使用该文件夹:${list}。重命名前将关闭它们(其标签页会消失,不会重新启动)。`, ], [/^Failed to (.+)$/, (_m, action) => `操作失败:${action}`], [/^Will create: (.+)$/, (_m, path) => `将创建:${path}`], diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index d74a5371..a342e37a 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -5067,12 +5067,17 @@ Object.assign(CodemanApp.prototype, { if (data.requiresStop) { const list = (data.sessions || []).map((s) => `${s.name}`).join(', '); state.stop = true; - this._renameCaseNotice(`These sessions have the folder open: ${list}. They will be stopped (nothing is restarted) before the folder is renamed.`); + this._renameCaseNotice(`These sessions have the folder open: ${list}. They will be closed (their tabs go away and nothing is restarted) before the folder is renamed.`); if (submit) submit.textContent = 'Stop sessions and rename'; return; } if (!data.success) { - this._renameCaseNotice(data.error || 'Failed to rename the case.'); + // A rename that failed after closing sessions says so: their tabs are gone either way. + const closed = Number(data.stoppedSessions) || 0; + const message = data.error || 'Failed to rename the case.'; + this._renameCaseNotice( + closed > 0 ? `${message} (${closed} session${closed === 1 ? ' was' : 's were'} already closed.)` : message + ); return; } this.closeRenameCase(); diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 9f1c1a53..1d2d41b3 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -1819,8 +1819,20 @@ export function registerCaseRoutes( const moveFolder = !isLinked || body.renameFolder === true; const newPath = isLinked ? (moveFolder ? siblingPath(oldPath, newName) : oldPath) : join(casesBase, newName); - if (moveFolder && newPath !== oldPath && (await probePath(newPath, { pastCap: true })) !== 'absent') { - return fail(409, ApiErrorCode.ALREADY_EXISTS, `A folder named ${newPath} already exists`); + if (moveFolder && newPath !== oldPath) { + // probePath follows symlinks, so a dangling one reads as absent; moveCaseFolder would still refuse it + // (it checks with lstat), but only after the sessions below were stopped. The probe answered for this + // very path, so the lstat after it is not a first touch of an unreachable mount. + const target = await probePath(newPath, { pastCap: true }); + const danglingLink = + target === 'absent' && + (await fs.lstat(newPath).then( + () => true, + () => false + )); + if (target !== 'absent' || danglingLink) { + return fail(409, ApiErrorCode.ALREADY_EXISTS, `A folder named ${newPath} already exists`); + } } // Sessions with the folder open. Only a move disturbs them; an alias-only rename leaves them running. diff --git a/test/case-rename-ui.test.ts b/test/case-rename-ui.test.ts index 8de64dca..4abee9c3 100644 --- a/test/case-rename-ui.test.ts +++ b/test/case-rename-ui.test.ts @@ -170,6 +170,8 @@ describe('renameCase / submitRenameCase', () => { 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); @@ -179,6 +181,20 @@ describe('renameCase / submitRenameCase', () => { 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'); diff --git a/test/case-rename.test.ts b/test/case-rename.test.ts index 5d3d2fe6..09dff6b9 100644 --- a/test/case-rename.test.ts +++ b/test/case-rename.test.ts @@ -3,7 +3,7 @@ * 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 { 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'; @@ -135,7 +135,7 @@ describe('Claude history', () => { 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 () => { + 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'); @@ -145,12 +145,59 @@ describe('Claude history', () => { 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); }); + + 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', () => { diff --git a/test/routes/case-rename-routes.test.ts b/test/routes/case-rename-routes.test.ts index 6816a999..780f4635 100644 --- a/test/routes/case-rename-routes.test.ts +++ b/test/routes/case-rename-routes.test.ts @@ -5,7 +5,16 @@ * 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, writeFileSync } from 'node:fs'; +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'; @@ -72,6 +81,43 @@ describe('POST /api/cases/:name/rename (a case under the cases directory)', () = 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).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');