diff --git a/CLAUDE.md b/CLAUDE.md index 038e2d3c..a1d94e10 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`: 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. +**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 is constructed mid-move). ⚠️ A create awaits the owner's layout lock and a settings read between `new Session` and the server's map, where the rename looks for open sessions, so the constructor also records the session (`noteCaseSessionStarting`) until `registerSessionWithLayout()` or `stop()` settles it (30 s TTL for one that never registers), and a folder move refuses `409` with `renameCode: "BUSY"` while one is recorded at or under the folder (`isCaseSessionStarting`, checked in the same tick that takes the lock); an alias-only rename is not refused. A create whose path was validated before the move but whose `Session` is constructed after it is not covered (the plain check-then-use race of any create path), `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 b6667be5..a8bdb25e 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1227,7 +1227,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 (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. +`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); a session that is still being created in the folder (constructed, not yet registered, so it can be neither listed nor stopped) makes a folder move `409` with `renameCode: "BUSY"` too, and a retry once it is registered lists it like any open session; 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 c9a134f1..c5762d60 100644 --- a/docs/wiki/Core-Concepts.md +++ b/docs/wiki/Core-Concepts.md @@ -41,7 +41,8 @@ Remote and Docker cases cannot be renamed. Renaming does the following for you: 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. + inside it half-moved. A session that is just being started there stops the rename, which says so; + trying again a moment later lists that session with the others. - **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). diff --git a/src/case-rename.ts b/src/case-rename.ts index 82a34bda..e8b46012 100644 --- a/src/case-rename.ts +++ b/src/case-rename.ts @@ -10,6 +10,8 @@ * * - the in-process lock (`withCaseLock` / `isCasePathLocked`) that keeps a session from starting in a * folder while it is being moved, and two renames from overlapping; + * - the record of sessions constructed but not yet registered (`isCaseSessionStarting`), which a + * rename refuses to move a folder under, since it can neither list nor stop them; * - the move itself, retried a few times for a transient lock and turned into a message that says * what to do when it is a real one; * - carrying Claude Code's history for the folder over to the new path. @@ -54,6 +56,43 @@ export function isCasePathLocked(path: string): boolean { return false; } +/** + * Sessions constructed but not yet in the server's session map, where the rename route looks for the + * sessions it must list or stop. A create awaits a few things between `new Session` and that map (the + * owner's layout lock, a settings read), and a rename that ran in that gap neither saw nor stopped the + * session, moved the folder, and left it to start in a path that no longer existed. + */ +const starting = new Map(); +/** A session that never reaches the map (a create that failed, a one-shot prompt) stops counting after this. */ +const STARTING_TTL_MS = 30000; + +const dropExpiredStarts = (now: number): void => { + for (const [session, entry] of starting) { + if (entry.until <= now) starting.delete(session); + } +}; + +/** `session` (any object standing for it) is being created with `workingDir`; `Session`'s constructor calls this. */ +export function noteCaseSessionStarting(session: object, workingDir: string, now = Date.now()): void { + dropExpiredStarts(now); + starting.set(session, { path: resolve(workingDir), until: now + STARTING_TTL_MS }); +} + +/** `session` is in the server's session map (or was stopped): the rename route sees it from here on. */ +export function caseSessionSettled(session: object): void { + starting.delete(session); +} + +/** True when a session is being created at or inside `path`, so a rename of it must wait. */ +export function isCaseSessionStarting(path: string, now = Date.now()): boolean { + dropExpiredStarts(now); + const root = resolve(path); + for (const entry of starting.values()) { + if (within(entry.path, root)) return true; + } + return false; +} + /** * Hold `from` and `to` for the length of `fn`. A second caller touching either (or anything inside * them) gets `BUSY` instead of racing the move; `Session`'s constructor refuses a working directory diff --git a/src/session.ts b/src/session.ts index 18f6ac32..1cb18fe9 100644 --- a/src/session.ts +++ b/src/session.ts @@ -151,7 +151,7 @@ import { } from './session-attachment-history.js'; import type { SessionAttachmentHistoryItem, DisplayModel } from './types/session.js'; import { resolveConfigModel } from './model-config-resolvers.js'; -import { isCasePathLocked } from './case-rename.js'; +import { caseSessionSettled, isCasePathLocked, noteCaseSessionStarting } from './case-rename.js'; import { legacyConfigForMode } from './session-cli-registry-bridge.js'; import { launchModelFor, @@ -954,6 +954,10 @@ export class Session extends EventEmitter { if (config.workingDir && isCasePathLocked(config.workingDir)) { throw new Error(`The case folder ${config.workingDir} is being renamed. Try again in a moment.`); } + // And a rename must not start until this session is in the server's map, where it can be listed and + // stopped: until registerSessionWithLayout() (or stop()) settles it, the rename route refuses the move. + // A remote session's workingDir is the remote path, which the route never counts as the folder's. + if (config.workingDir && !config.remote) noteCaseSessionStarting(this, config.workingDir); this.setMaxListeners(25); // Default error handler prevents unhandled 'error' events from crashing the process. @@ -5181,6 +5185,8 @@ export class Session extends EventEmitter { async stop(killMux: boolean = true): Promise { // Set stopped flag first to prevent new timers from being created this._isStopped = true; + // A session stopped before it was registered (a create that failed) no longer holds up a case rename. + caseSessionSettled(this); // A pane that is gone is watching nothing. Nothing probes a stopped session, so // without this the last chip it drew would ride along on its row forever. this._watching = null; diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 1d2d41b3..7fbe7b5e 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -55,6 +55,7 @@ import { generateClaudeMd } from '../../templates/claude-md.js'; import { prepareNewCasePath } from '../case-path.js'; import { CaseRenameError, + isCaseSessionStarting, moveCaseFolder, moveClaudeHistory, renameInOrder, @@ -1848,6 +1849,14 @@ export function registerCaseRoutes( if (open.some((s) => !canAccessOwned(user, s.owner))) { return fail(403, ApiErrorCode.FORBIDDEN, "Another user's session has this case open"); } + // A session being created in the folder is not in ctx.sessions yet, so it could be neither listed nor + // stopped: it would start in the old path after the move. Checked in the same tick that takes the lock + // below (nothing is awaited in between), and the Session constructor refuses the folder from then on. + if (moveFolder && isCaseSessionStarting(oldPath)) { + return fail(409, ApiErrorCode.CONFLICT, 'A session is starting in this case. Try again in a moment.', { + renameCode: 'BUSY', + }); + } if (open.length > 0 && !body.stopSessions) { return fail( 409, diff --git a/src/web/server.ts b/src/web/server.ts index 0b3d0fcc..2b3e582c 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -242,6 +242,7 @@ import { } from './routes/index.js'; import { isLostWebviewFrameNavigation } from './webview-proxy.js'; import { CronService } from '../cron/cron-service.js'; +import { caseSessionSettled } from '../case-rename.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -806,6 +807,8 @@ export class WebServer extends EventEmitter { private async registerSessionWithLayout(session: Session, placement?: SessionPlacementHint): Promise { return this.tabLayouts.sessionCreated(ownerLayoutKey(session.owner), session.id, placement, () => { this.sessions.set(session.id, session); + // In the map now, so a case rename lists (and on request stops) it instead of refusing the move. + caseSessionSettled(session); return () => { if (this.sessions.get(session.id) === session) this.sessions.delete(session.id); }; @@ -3957,6 +3960,7 @@ export class WebServer extends EventEmitter { } this.sessions.set(session.id, session); + caseSessionSettled(session); await this.setupSessionListeners(session); // Auto-attach PTY to the surviving tmux session immediately. diff --git a/test/case-rename-create-race.test.ts b/test/case-rename-create-race.test.ts new file mode 100644 index 00000000..abcca93c --- /dev/null +++ b/test/case-rename-create-race.test.ts @@ -0,0 +1,103 @@ +/** + * @fileoverview A case rename racing a session create, against the real WebServer. + * + * A create constructs its `Session` (which checks the rename lock) and only puts it in the server's session + * map later, after awaiting the owner's layout lock and a settings read. A rename that ran in that gap built its + * list of open sessions without the new one, moved the folder, and the create then registered a session whose + * pane would `cd` into a path that no longer existed. The rename now refuses the move while such a session is + * still being created, and the server's registration is what lets go of it. + * + * The gap is widened by holding the settings read inside `TabLayoutService.sessionCreated()` + * (`childrenFollowParent`), the await between the constructor and the registration callback. + * + * Port: 0 (ephemeral, read back from `server.boundPort`). + */ +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { WebServer } from '../src/web/server.js'; +import { isCaseSessionStarting } from '../src/case-rename.js'; +import { safeRmHomeTree } from './mocks/test-helpers.js'; + +interface LayoutDeps { + childrenFollowParent?: () => Promise; +} + +let server: WebServer; +const made: string[] = []; +const url = (path: string) => `http://localhost:${server.boundPort}${path}`; +const post = async (path: string, body: unknown) => { + const res = await fetch(url(path), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + return { status: res.status, json: (await res.json()) as Record }; +}; +const localCase = (name: string) => { + const dir = join(homedir(), 'codeman-cases', name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'CLAUDE.md'), name); + made.push(dir); + return dir; +}; + +beforeAll(async () => { + server = new WebServer(0, false, true); + await server.start(); +}, 60000); + +afterAll(async () => { + await server?.stop(); + for (const dir of made.splice(0)) safeRmHomeTree(dir); +}, 60000); + +describe('renaming a case while a session is being created in it', () => { + it('refuses the move until the session is registered, then lists it like any open session', async () => { + const dir = localCase('race-alpha'); + made.push(join(homedir(), 'codeman-cases', 'race-beta')); // only there if a rename got through + const deps = (server as unknown as { tabLayouts: { deps: LayoutDeps } }).tabLayouts.deps; + const original = deps.childrenFollowParent; + let reached!: () => void; + const atGap = new Promise((resolve) => (reached = resolve)); + let release!: () => void; + const gate = new Promise((resolve) => (release = resolve)); + deps.childrenFollowParent = async () => { + reached(); + await gate; + return (await original?.()) === true; + }; + try { + const create = post('/api/sessions', { workingDir: dir, mode: 'shell' }); + await atGap; // constructed, not yet in the server's session map + expect(isCaseSessionStarting(dir)).toBe(true); + + // Without the refusal the rename moves the folder and then waits on the owner's layout lock, which the + // held create owns: bound the wait so that shows up as a failure rather than a test timeout. + const busy = await Promise.race([ + post('/api/cases/race-alpha/rename', { newName: 'race-beta', stopSessions: true }), + new Promise((resolve) => setTimeout(() => resolve(null), 5000)), + ]); + expect(existsSync(dir), 'the folder moved under the session being created').toBe(true); + expect(busy?.status).toBe(409); + expect(busy?.json).toMatchObject({ errorCode: 'CONFLICT', renameCode: 'BUSY' }); + + deps.childrenFollowParent = original; + release(); + const created = await create; + expect(created.status).toBe(200); + const id = created.json.data.session.id as string; + expect(isCaseSessionStarting(dir)).toBe(false); + + const listed = await post('/api/cases/race-alpha/rename', { newName: 'race-beta' }); + expect(listed.status).toBe(409); + expect(listed.json).toMatchObject({ requiresStop: true, sessions: [{ id }] }); + expect(existsSync(dir)).toBe(true); + await fetch(url(`/api/sessions/${id}`), { method: 'DELETE' }); + } finally { + deps.childrenFollowParent = original; + release(); + } + }); +}); diff --git a/test/case-rename.test.ts b/test/case-rename.test.ts index 09dff6b9..287a45a0 100644 --- a/test/case-rename.test.ts +++ b/test/case-rename.test.ts @@ -1,6 +1,6 @@ /** - * @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. + * @fileoverview src/case-rename.ts: the in-process lock, the record of sessions still being created, 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'; @@ -9,9 +9,12 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { CaseRenameError, + caseSessionSettled, claudeProjectDirName, isCasePathLocked, + isCaseSessionStarting, moveCaseFolder, + noteCaseSessionStarting, moveClaudeHistory, renameInOrder, siblingPath, @@ -78,6 +81,32 @@ describe('withCaseLock', () => { }); }); +describe('sessions still being created', () => { + it('counts a constructed session as starting in its folder until the server settles it', () => { + const root = tmp(); + const dir = join(root, 'case'); + const session = new Session({ workingDir: join(dir, 'sub') }); + expect(isCaseSessionStarting(dir)).toBe(true); + expect(isCaseSessionStarting(join(dir, 'sub'))).toBe(true); + expect(isCaseSessionStarting(join(dir, 'other'))).toBe(false); + expect(isCaseSessionStarting(`${dir}-2`)).toBe(false); + caseSessionSettled(session); + expect(isCaseSessionStarting(dir)).toBe(false); + }); + + it('lets go of a session that is stopped, and of one never registered once a while has passed', async () => { + const root = tmp(); + const stopped = new Session({ workingDir: root }); + await stopped.stop(); + expect(isCaseSessionStarting(root)).toBe(false); + // a create that failed before registering, or a one-shot prompt that never registers + const lost = {}; + noteCaseSessionStarting(lost, root, 1000); + expect(isCaseSessionStarting(root, 1000 + 29_999)).toBe(true); + expect(isCaseSessionStarting(root, 1000 + 30_000)).toBe(false); + }); +}); + describe('moveCaseFolder', () => { it('moves a folder and its contents', async () => { const root = tmp(); diff --git a/test/routes/case-rename-routes.test.ts b/test/routes/case-rename-routes.test.ts index 780f4635..94a58721 100644 --- a/test/routes/case-rename-routes.test.ts +++ b/test/routes/case-rename-routes.test.ts @@ -22,7 +22,8 @@ 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 { caseSessionSettled, claudeProjectDirName } from '../../src/case-rename.js'; +import { Session } from '../../src/session.js'; import { safeRmHomeTree } from '../mocks/index.js'; const LINKED = () => dataPath('linked-cases.json'); @@ -156,6 +157,28 @@ describe('POST /api/cases/:name/rename (a case under the cases directory)', () = expect(existsSync(join(CASES_DIR(), 'beta'))).toBe(true); }); + it('refuses to move a folder a session is still being created in, since it can neither list nor stop it', async () => { + const { app, ctx } = await createRouteTestHarness(registerCaseRoutes); + const oldDir = localCase('alpha'); + // Constructed but not in ctx.sessions yet: the gap a create spends awaiting the layout lock and settings. + const session = new Session({ workingDir: join(oldDir, 'sub') }); + try { + const busy = await rename(app, 'alpha', { newName: 'beta', stopSessions: true }); + expect(busy.statusCode).toBe(409); + expect(busy.json()).toMatchObject({ errorCode: 'CONFLICT', renameCode: 'BUSY' }); + expect(existsSync(oldDir)).toBe(true); + expect(ctx.cleanupSession).not.toHaveBeenCalled(); + // once registered it is an open session like any other: listed, and stopped only on request + (ctx.sessions as Map).set(session.id, session); + caseSessionSettled(session); + const listed = await rename(app, 'alpha', { newName: 'beta' }); + expect(listed.statusCode).toBe(409); + expect(listed.json()).toMatchObject({ requiresStop: true, sessions: [{ id: session.id }] }); + } finally { + caseSessionSettled(session); + } + }); + 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'); @@ -215,6 +238,23 @@ describe('POST /api/cases/:name/rename (a linked case)', () => { expect(ctx.cleanupSession).not.toHaveBeenCalled(); }); + it('renames only the alias while a session is being created in the folder, which an alias rename leaves alone', async () => { + const { app } = await createRouteTestHarness(registerCaseRoutes); + const real = join(work, 'my-project'); + mkdirSync(real); + writeFileSync(LINKED(), JSON.stringify({ proj: real })); + const session = new Session({ workingDir: real }); + try { + expect((await rename(app, 'proj', { newName: 'renamed' })).statusCode).toBe(200); + expect((await rename(app, 'renamed', { newName: 'moved', renameFolder: true })).json()).toMatchObject({ + renameCode: 'BUSY', + }); + expect(existsSync(real)).toBe(true); + } finally { + caseSessionSettled(session); + } + }); + 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');