diff --git a/.changeset/rename-cases.md b/.changeset/rename-cases.md new file mode 100644 index 00000000..08e0022e --- /dev/null +++ b/.changeset/rename-cases.md @@ -0,0 +1,5 @@ +--- +"aicodeman": minor +--- + +Rename a case and its folder from the case manager. A case under `~/codeman-cases` is renamed as a name and folder; a linked case can rename only its name or its real folder too. Live sessions that have the folder open are listed and only stopped if you agree, the folder is locked while it moves so nothing starts in it half-moved, a folder another program holds open is reported instead of forced, and Claude Code's conversation history (including the Resume list), the case order, the last used case and the default session group follow the new name. diff --git a/CLAUDE.md b/CLAUDE.md index 96ad711c..dcc23758 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -181,6 +181,8 @@ 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. + **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) ⚠️ **llama-swap endpoints** (one model at a time): the apply routes check `GET /running` and return `requiresConfirmation` before evicting a model another live session uses; `confirmedSwap` and `confirmedContext` are SEPARATE flags and must stay so. Claude alone gets a context floor (`CLAUDE_MIN_SAFE_CONTEXT_TOKENS`); context is parsed from `/running`'s `cmd`, never trusted from `/props`. The floor also divides that context by the model's parallel slot count (`modelSlotCounts`: `--parallel`/`-np` from `cmd`, else `/props` `total_slots`; `perSlotContext()`), and `/props` only ever RAISES that warning: it supplies the context only when `cmd` states none and the count only when `cmd` has no `--parallel`, never a larger pool. `/props` is probed once per launch command (it refreshes llama-swap's last-use time, so a `ttl` would never expire), and a `/props` context beside an explicit `--parallel N` is already per slot, so it is not divided again. Backend log lines come from llama-swap's `/api/events` `upstream` source, never `/logs`. → [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 32774fbf..e4da1ddc 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1129,11 +1129,14 @@ A local case whose folder is the top of a git repository can keep **worktrees** | `PUT` | `/api/settings` | partial settings (strict schema) | Merged into the stored file; services (watchers, tunnel, tmux history limit) reconcile to the merged result. `403 FORBIDDEN` for a non-admin in multi-user mode. A tunnel enable without `CODEMAN_PASSWORD` is refused separately (`403 OPERATION_FAILED`, resend with `acknowledgeUnauthTunnel: true`). | | `GET` | `/api/execution/model-config` | none | `modelConfig` (cron's and the plan orchestrator's default model). | | `PUT` | `/api/execution/model-config` | `{ defaultModel?, agentTypeOverrides?, ... }` | Replaces `modelConfig`. `403 FORBIDDEN` for a non-admin in multi-user mode. | +| `POST` | `/api/cases/:name/rename` | `{ newName, renameFolder?, stopSessions? }` (strict) | Rename a case and its folder (see below). | | `PUT` | `/api/cases/order` | `{ order: string[] }` | The one case order every user's `GET /api/cases` is sorted by. `403 FORBIDDEN` for a non-admin in multi-user mode. | | `PUT` | `/api/config` | `{ pollIntervalMs?, defaultTimeoutMs?, maxConcurrentSessions?, respawn? }` (strict) | The process-wide app config in `state.json`. `403 FORBIDDEN` for a non-admin in multi-user mode. | 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. + ### System operations The same multi-user rule covers the operations that act on the whole server. For a non-admin: diff --git a/docs/wiki/Core-Concepts.md b/docs/wiki/Core-Concepts.md index ae5ac504..c9a134f1 100644 --- a/docs/wiki/Core-Concepts.md +++ b/docs/wiki/Core-Concepts.md @@ -14,23 +14,51 @@ Codeman-side configuration: - Which CLI the Run button should default to. - Per-case toggles (Agent Teams, 1M Opus context). - A **Default session group** (Case settings…): new sessions of the case land in that tab group of the - vertical rail unless you start them from a group's own *New session* or they follow the session that - spawned them. *Ungrouped* turns it off. It follows you across devices, never moves existing tabs, and a + vertical rail unless you start them from a group's own _New session_ or they follow the session that + spawned them. _Ungrouped_ turns it off. It follows you across devices, never moves existing tabs, and a deleted group simply falls back to Ungrouped. - Where it runs, if it is not the local filesystem: see [Location overlays](#location-overlays). Three ways to get one, all under **New or link a case…** at the bottom of the case picker (the case dropdown in the bottom toolbar; on a phone, the case sheet's **Create New Case**): -| How | Result | -| ----------------- | ------------------------------------------------------------------------------------------------------ | +| How | Result | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Create New** | A fresh `~/codeman-cases/` with a scaffolded `CLAUDE.md`, or with **Create in a custom folder**, a new folder inside a parent you choose, scaffolded the same way and registered in place like a linked case. | -| **Clone Repo** | A repo cloned into `~/codeman-cases/` and registered as a case. Private repos need this machine's own git credentials (see below). | -| **Link Existing** | An existing folder anywhere on disk, registered in place. Nothing is copied or moved. | +| **Clone Repo** | A repo cloned into `~/codeman-cases/` and registered as a case. Private repos need this machine's own git credentials (see below). | +| **Link Existing** | An existing folder anywhere on disk, registered in place. Nothing is copied or moved. | Linked cases keep living where they are. Deleting a case in Codeman removes the registration, and for a linked case that is all it removes. +**Renaming a case.** In the case manager (Settings, or the case picker's manage view) the pencil +button renames a case and its folder. A case under `~/codeman-cases` _is_ its folder name, so it +always moves. A linked case renames its name in the list only, unless you tick **Also rename the +folder on disk** in the rename dialog: then its real folder is renamed too, next to where it is. +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. 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. 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 + edit them; Claude history of sessions started in a subfolder (including `.claude/worktrees/`) + stays under the old path. Claude Code's own per-project settings in `~/.claude.json` (local-scope MCP + servers, allowed tools, the folder's trust) are keyed by the old path too, so the renamed case starts + without them: Codeman does not write another CLI's configuration here. In multi-user mode only an + admin's rename updates the shared case order and last used case. +- **The case order, the last used case and this browser's per-case options follow the new name.** + **Clone Repo never asks for credentials.** It uses whatever the server's own git already has: an ssh key, or a credential helper such as `gh auth setup-git`. The Docker image can include helpers for GitHub (`gh`) and Azure DevOps (`az`), turned on in `docker-compose.override.yml`; @@ -75,10 +103,10 @@ Claude-only where it appears, and [Agent CLIs](Agent-CLIs) lists them in one pla Where a case runs is **separate from** which CLI it runs. There are three locations: -| Location | What happens | -| -------------- | ------------------------------------------------------------------------------------------------------------ | -| **Local** | The default. tmux and the CLI run on the Codeman host. | -| **Docker** | One long-lived container per case; sessions `docker exec` into it. See [Docker Cases](Docker-Cases). | +| Location | What happens | +| -------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| **Local** | The default. tmux and the CLI run on the Codeman host. | +| **Docker** | One long-lived container per case; sessions `docker exec` into it. See [Docker Cases](Docker-Cases). | | **Remote SSH** | A durable tmux server on the remote host, fronted by a local pane running `ssh`. See [Remote SSH Sessions](Remote-SSH-Sessions). | This matters because it is a common source of confusion: Docker is **not** an eleventh run @@ -113,14 +141,14 @@ sessions never appear in a bare `tmux ls`. ## What persists -| Survives | Does not survive | -| -------------------------------------------- | --------------------------------------------------- | -| Closing the browser | `tmux -L codeman kill-server` | -| Losing the network | A machine reboot (tmux dies with it) | -| Restarting the Codeman server | Killing the session from the UI | -| `codeman web --stop` | | -| A dropped SSH link, for remote cases | | -| A container restart, for docker cases | | +| Survives | Does not survive | +| ------------------------------------- | ------------------------------------ | +| Closing the browser | `tmux -L codeman kill-server` | +| Losing the network | A machine reboot (tmux dies with it) | +| Restarting the Codeman server | Killing the session from the UI | +| `codeman web --stop` | | +| A dropped SSH link, for remote cases | | +| A container restart, for docker cases | | Conversation history is a separate question: Claude transcripts live in `~/.claude/`, so a conversation can be resumed even after the tmux session is gone. That is what the welcome @@ -130,18 +158,18 @@ screen's **Resume Conversation** list offers. Everything Codeman knows lives under `~/.codeman/`: -| File | Holds | -| ---------------------------------------- | -------------------------------------------------------------------- | -| `state.json` | Sessions, settings, respawn config, orchestrator state, cron jobs. | -| `settings.json` | User preferences that sync across your devices. | -| `mux-sessions.json` | tmux recovery data. | -| `session-lifecycle.jsonl` | Append-only audit log of session starts, exits, and kills. | -| `linked-cases.json` | Registered cases. | -| `remote-hosts.json`, `docker-hosts.json` | Location overlay configuration. | -| `webviews.json` | Saved dashboard URLs. | -| `users.json` | Multi-user accounts, mode 0600. | -| `push-*.json` | Web push keys and subscriptions. | -| `certs/` | Self-signed TLS for `--https`. | +| File | Holds | +| ---------------------------------------- | --------------------------------------------------------------------------------------------- | +| `state.json` | Sessions, settings, respawn config, orchestrator state, cron jobs. | +| `settings.json` | User preferences that sync across your devices. | +| `mux-sessions.json` | tmux recovery data. | +| `session-lifecycle.jsonl` | Append-only audit log of session starts, exits, and kills. | +| `linked-cases.json` | Registered cases. | +| `remote-hosts.json`, `docker-hosts.json` | Location overlay configuration. | +| `webviews.json` | Saved dashboard URLs. | +| `users.json` | Multi-user accounts, mode 0600. | +| `push-*.json` | Web push keys and subscriptions. | +| `certs/` | Self-signed TLS for `--https`. | | `backups/config/` | Timestamped copies of the files above that you customized ([Config Backups](Config-Backups)). | None of it needs root, none of it leaves the machine, and deleting `~/.codeman/` resets @@ -184,17 +212,17 @@ See [Hooks And Integrations](Hooks-And-Integrations). ## Vocabulary -| Term | Means | -| --------------- | ---------------------------------------------------------------------------- | -| **Case** | Named working directory. | -| **Session** | One CLI in one tmux session. | -| **Run mode** | Which CLI: claude, opencode, codex, gemini, antigravity, pi, grok, deepseek, omp, copilot, shell. | -| **Respawn** | Restarting the CLI on idle to keep an unattended run going. | -| **Ralph loop** | An autonomous single-session task loop. | -| **Orchestrator**| A phased plan driven across multiple agents. | -| **Subagent** | An agent the CLI spawned itself, shown live in its own window. | -| **Web tab** | A saved dashboard URL rendered as a tab. Not a session. | -| **Instance** | One Codeman server with its own data directory and tmux socket. | +| Term | Means | +| ---------------- | ------------------------------------------------------------------------------------------------- | +| **Case** | Named working directory. | +| **Session** | One CLI in one tmux session. | +| **Run mode** | Which CLI: claude, opencode, codex, gemini, antigravity, pi, grok, deepseek, omp, copilot, shell. | +| **Respawn** | Restarting the CLI on idle to keep an unattended run going. | +| **Ralph loop** | An autonomous single-session task loop. | +| **Orchestrator** | A phased plan driven across multiple agents. | +| **Subagent** | An agent the CLI spawned itself, shown live in its own window. | +| **Web tab** | A saved dashboard URL rendered as a tab. Not a session. | +| **Instance** | One Codeman server with its own data directory and tmux socket. | ## Read next diff --git a/src/case-rename.ts b/src/case-rename.ts new file mode 100644 index 00000000..82a34bda --- /dev/null +++ b/src/case-rename.ts @@ -0,0 +1,187 @@ +/** + * @fileoverview Renaming a case (its name and its folder) without losing what points at the folder. + * + * A case folder is held in more places than an open file. A running agent has it as its working + * directory and its tmux pane was started `cd`-ed into it; Claude Code keys its conversation history + * by the folder's path (`/projects/`), so a moved + * folder silently orphans every conversation; on Windows, SMB and some FUSE mounts a directory with an + * open handle simply cannot be renamed (EBUSY / EPERM / EACCES). This module owns the three parts of + * that which are not route logic: + * + * - 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 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. + * + * Pure of the server: every function takes the paths it works on. + * + * @module case-rename + */ + +import fs from 'node:fs/promises'; +import { dirname, join, resolve, sep } from 'node:path'; +import { claudeProjectsDir } from './utils/claude-transcript.js'; + +export type CaseRenameErrorCode = + | 'INVALID' // a name that is not allowed, or the same name + | 'EXISTS' // the new name or folder is already taken + | 'BUSY' // another rename (or a session being stopped) holds the folder + | 'LOCKED' // the OS refused the move: something still has the folder open + | 'CROSS_DEVICE' // the move would cross filesystems + | 'UNSUPPORTED'; // remote and Docker cases + +export class CaseRenameError extends Error { + constructor( + readonly code: CaseRenameErrorCode, + message: string + ) { + super(message); + this.name = 'CaseRenameError'; + } +} + +const locked = new Set(); + +const within = (child: string, parent: string): boolean => child === parent || child.startsWith(parent + sep); + +/** True when `path` is, or is inside, a folder that is being renamed right now. */ +export function isCasePathLocked(path: string): boolean { + const target = resolve(path); + for (const held of locked) { + if (within(target, held)) 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 + * inside a held folder, so nothing starts in a case that is half-moved. + */ +export async function withCaseLock(from: string, to: string, fn: () => Promise): Promise { + const held = [resolve(from), resolve(to)]; + for (const p of held) { + for (const other of locked) { + if (within(p, other) || within(other, p)) { + throw new CaseRenameError('BUSY', 'Another rename is already working on this case. Try again in a moment.'); + } + } + } + for (const p of held) locked.add(p); + try { + return await fn(); + } finally { + for (const p of held) locked.delete(p); + } +} + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const LOCK_CODES = new Set(['EBUSY', 'EPERM', 'EACCES']); + +/** + * `rename(from, to)` for a folder that must not already exist at `to`. A lock error is retried (a + * handle another process is closing, an antivirus scan) before it is reported as locked. + * `fs.rename` onto an EMPTY existing directory replaces it on POSIX, so the target is checked first. + */ +export async function moveCaseFolder(from: string, to: string, attempts = 4, delayMs = 150): Promise { + if ( + await fs.lstat(to).then( + () => true, + () => false + ) + ) { + throw new CaseRenameError('EXISTS', `A folder named ${to} already exists.`); + } + for (let i = 0; ; i++) { + try { + await fs.rename(from, to); + return; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code ?? ''; + if (code === 'EXDEV') { + throw new CaseRenameError( + 'CROSS_DEVICE', + 'The new folder would be on a different drive or mount; it is not moved.' + ); + } + if (LOCK_CODES.has(code)) { + if (i < attempts - 1) { + await sleep(delayMs * (i + 1)); + continue; + } + throw new CaseRenameError( + 'LOCKED', + `The folder is in use by another program (${code}), so it could not be renamed. Close anything that has it open ` + + '(an editor, a file browser or a sync client) and try again.' + ); + } + if (code === 'ENOTEMPTY' || code === 'EEXIST') { + throw new CaseRenameError('EXISTS', `A folder named ${to} already exists.`); + } + throw err; + } + } +} + +/** The directory name Claude Code uses for a project path: every non-alphanumeric becomes `-`. */ +export function claudeProjectDirName(path: string): string { + return resolve(path).replace(/[^a-zA-Z0-9]/g, '-'); +} + +/** + * Move Claude Code's history for the old folder to the new one, so `--resume` and the past-sessions list + * 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; + 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). */ +export function renameInOrder(order: unknown, from: string, to: string): string[] | null { + if (!Array.isArray(order) || !order.includes(from)) return null; + return order.map((n) => (n === from ? to : n)) as string[]; +} + +/** The folder a linked case moves to: its own parent, the new name. */ +export const siblingPath = (path: string, newName: string): string => join(dirname(path), newName); diff --git a/src/session.ts b/src/session.ts index 8ded0775..18f6ac32 100644 --- a/src/session.ts +++ b/src/session.ts @@ -151,6 +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 { legacyConfigForMode } from './session-cli-registry-bridge.js'; import { launchModelFor, @@ -948,6 +949,11 @@ export class Session extends EventEmitter { } ) { super(); + // A case folder being renamed (src/case-rename.ts) must not gain a session mid-move: its pane would + // start in a directory that is about to have a different path. + if (config.workingDir && isCasePathLocked(config.workingDir)) { + throw new Error(`The case folder ${config.workingDir} is being renamed. Try again in a moment.`); + } this.setMaxListeners(25); // Default error handler prevents unhandled 'error' events from crashing the process. diff --git a/src/web/public/constants.js b/src/web/public/constants.js index a51241fc..eb16edcd 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -1924,6 +1924,7 @@ const SSE_EVENTS = { CASE_CREATED: 'case:created', CASE_LINKED: 'case:linked', CASE_DELETED: 'case:deleted', + CASE_RENAMED: 'case:renamed', CASE_ORDER_CHANGED: 'case:order-changed', DOCKER_EXPORT_COMPLETE: 'docker:exportComplete', DOCKER_EXPORT_FAILED: 'docker:exportFailed', diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index bca8aaa1..0f17c78d 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -86,6 +86,17 @@ 'Web tab settings': '网页标签设置', 'New group': '新建分组', 'Rename group': '重命名分组', + 'Rename case': '重命名案例', + Rename: '重命名', + 'New name': '新名称', + 'letters, numbers, - and _': '字母、数字、- 和 _', + 'Also rename the folder on disk': '同时重命名磁盘上的文件夹', + 'A case under the cases folder is renamed together with its folder.': + '案例文件夹下的案例会连同其文件夹一起重命名。', + 'Stop sessions and rename': '停止会话并重命名', + 'Enter a different name.': '请输入不同的名称。', + 'Use only letters, numbers, hyphens and underscores.': '只能使用字母、数字、连字符和下划线。', + 'Failed to rename the case.': '重命名案例失败。', 'Move group up': '上移分组', 'Move group down': '下移分组', 'Delete group': '删除分组', @@ -1336,6 +1347,12 @@ (_m, version, shown, more) => `Codeman 已更新到 v${version}:${shown}${more ? `(另有 ${more} 项)` : ''}`, ], [/^Selected: (.+)$/, (_m, value) => `已选择:${value}`], + [/^Case renamed to "(.+)"$/, (_m, name) => `案例已重命名为“${name}”`], + [/^New name for "(.+)"$/, (_m, name) => `“${name}”的新名称`], + [ + /^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}`], // The spreadsheet preview: an HTTP status, and the notice bar's counts. diff --git a/src/web/public/index.html b/src/web/public/index.html index a6b644da..69574bfb 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3818,6 +3818,35 @@ + + + @@ -5007,6 +5013,113 @@ Object.assign(CodemanApp.prototype, { await this.saveCaseOrder(reordered.map(c => c.name)); }, + /** + * Rename a case and its folder (POST /api/cases/:name/rename), in the app's own dialog. + * + * A linked case shows a checkbox for renaming the real folder too (unticked = the name only). When live + * sessions have the folder open the server refuses and lists them; the dialog then lists them and its button + * becomes "Stop sessions and rename", so they are only stopped after a second, explicit press. Errors from + * the server (a taken name, a folder another program has open) stay inside the dialog so the name can be fixed. + */ + renameCase(name) { + const info = (this.cases || []).find((c) => c.name === name); + this._renameCaseState = { name, linked: Boolean(info?.linked), stop: false, busy: false }; + const input = document.getElementById('renameCaseInput'); + const label = document.getElementById('renameCaseLabel'); + if (label) label.textContent = `New name for "${name}"`; + if (input) input.value = name; + const row = document.getElementById('renameCaseFolderRow'); + row?.classList.toggle('hidden', !info?.linked); + const box = document.getElementById('renameCaseFolder'); + if (box) box.checked = false; + const hint = document.getElementById('renameCaseFolderHint'); + if (hint) hint.textContent = info?.linked ? `${info.path} (unticked: only the name in this list changes)` : ''; + document.getElementById('renameCaseNote')?.classList.toggle('hidden', Boolean(info?.linked)); + this._renameCaseNotice(''); + const submit = document.getElementById('renameCaseSubmit'); + if (submit) submit.textContent = 'Rename'; + document.getElementById('renameCaseModal')?.classList.add('active'); + input?.focus?.(); + input?.select?.(); + }, + + closeRenameCase() { + document.getElementById('renameCaseModal')?.classList.remove('active'); + this._renameCaseState = null; + }, + + /** Show (or clear, with '') a message inside the rename dialog. */ + _renameCaseNotice(message) { + const el = document.getElementById('renameCaseError'); + if (!el) return; + el.textContent = message; + el.classList.toggle('hidden', !message); + }, + + async submitRenameCase() { + const state = this._renameCaseState; + if (!state || state.busy) return; + const name = state.name; + const newName = (document.getElementById('renameCaseInput')?.value || '').trim(); + if (!newName || newName === name) { + this._renameCaseNotice('Enter a different name.'); + return; + } + if (!/^[a-zA-Z0-9_-]+$/.test(newName)) { + this._renameCaseNotice('Use only letters, numbers, hyphens and underscores.'); + return; + } + const body = { newName }; + if (state.linked) body.renameFolder = Boolean(document.getElementById('renameCaseFolder')?.checked); + if (state.stop) body.stopSessions = true; + state.busy = true; + const submit = document.getElementById('renameCaseSubmit'); + if (submit) submit.disabled = true; + try { + const res = await fetch(`/api/cases/${encodeURIComponent(name)}/rename`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json().catch(() => ({ success: false, error: 'Unexpected response' })); + 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 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) { + // 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(); + this.showToast(`Case renamed to "${newName}"`, 'success'); + // The per-case run options are stored under the case name in this browser. + try { + const stored = localStorage.getItem('caseSettings_' + name); + if (stored !== null) { + localStorage.setItem('caseSettings_' + newName, stored); + localStorage.removeItem('caseSettings_' + name); + } + } catch { /* storage blocked: the options fall back to their defaults */ } + const select = document.getElementById('quickStartCase'); + const current = select?.value; + await this.loadQuickStartCases(current === name ? newName : current); + this.renderCaseManageList(); + } catch (err) { + this._renameCaseNotice('Failed to rename the case: ' + err.message); + } finally { + state.busy = false; + if (submit) submit.disabled = false; + } + }, + async deleteCase(name) { if (!confirm(`Delete case "${name}"? Linked cases will only be unlinked (folder preserved). Created cases will be permanently deleted.`)) { return; diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 8a679b91..1d2d41b3 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -10,7 +10,7 @@ import { FastifyInstance } from 'fastify'; import { existsSync, lstatSync, mkdirSync, writeFileSync, readdirSync, readFileSync, createReadStream } from 'node:fs'; import { exec } from 'node:child_process'; import fs from 'node:fs/promises'; -import { join, resolve, basename } from 'node:path'; +import { join, resolve, basename, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { homedir } from 'node:os'; import type { @@ -30,6 +30,7 @@ import { LinkCaseSchema, CaseOrderSchema, CaseDefaultGroupSchema, + RenameCaseSchema, RemoteCaseLinkSchema, RemoteHostSchema, DockerCaseLinkSchema, @@ -52,6 +53,14 @@ import { import type { GitRemoteProbe, GitUrlParse } from '../../git-clone.js'; import { generateClaudeMd } from '../../templates/claude-md.js'; import { prepareNewCasePath } from '../case-path.js'; +import { + CaseRenameError, + moveCaseFolder, + moveClaudeHistory, + renameInOrder, + siblingPath, + withCaseLock, +} from '../../case-rename.js'; import { boundedPathExists, describeUnknownPath, probePath, probePathKind } from '../../utils/index.js'; import { readAgentCaseMarker, type AgentCaseMarker } from '../../agent-case-marker.js'; import { settingsWriteBlocker, writeHooksConfig } from '../../hooks-config.js'; @@ -1747,6 +1756,199 @@ export function registerCaseRoutes( } }); + // ========== Rename Case ========== + + /** + * Rename a case: its name and, with it, its folder. + * + * - A case under the cases directory IS its folder name, so it always moves. + * - A linked case has an alias that is only a key in linked-cases.json; the real folder moves too only + * with `renameFolder: true` (it can be shared with other tools). + * - Remote and Docker cases are not renamable here: their folder lives on another host or in a container + * whose name derives from the case name. + * + * ⚠️ Moving a folder that sessions have open is the dangerous part (see src/case-rename.ts): live + * sessions in it are listed and refused (409 `requiresStop`) unless `stopSessions` is set, in which + * case they are ended first. The folder is held by an in-process lock for the whole operation, so a + * session cannot start in it half-moved. + */ + app.post('/api/cases/:name/rename', async (req, reply) => { + const { name } = req.params as { name: string }; + const user = getAuthUser(req); + const body = parseBody(RenameCaseSchema, req.body); + const newName = body.newName; + const fail = (status: number, code: ApiErrorCode, message: string, extra: Record = {}) => { + reply.code(status); + return { ...createErrorResponse(code, message), ...extra }; + }; + if (!SAFE_CASE_NAME.test(name) || !validatePathWithinBase(name, resolveCasesDir(user))) { + return fail(400, ApiErrorCode.INVALID_INPUT, 'Invalid case name'); + } + if (newName === name) return fail(400, ApiErrorCode.INVALID_INPUT, 'The new name is the same as the old one'); + + // Remote and Docker cases first, so a same-named local case cannot be confused for them. + const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR); + const dockerCases = await readDockerCases(CODEMAN_CONFIG_DIR); + if ( + remoteCases.some((c) => c.name === name && canAccessOwned(user, c.owner)) || + dockerCases.some((c) => c.name === name && canAccessOwned(user, c.owner)) + ) { + return fail(400, ApiErrorCode.INVALID_INPUT, 'Remote and Docker cases cannot be renamed here'); + } + + const casesBase = resolveCasesDir(user); + const linkedCases = await readLinkedCases(); + const isLinked = Boolean(linkedCases[name]) && (!isMultiUserMode() || isAdmin(req)); + const oldPath = isLinked ? linkedCases[name] : join(casesBase, name); + + // The new name must be free in every namespace the list merges. + const localTaken = existsSync(join(casesBase, newName)); + const taken = + localTaken || + Boolean(linkedCases[newName]) || + remoteCases.some((c) => c.name === newName) || + dockerCases.some((c) => c.name === newName); + if (taken) return fail(409, ApiErrorCode.ALREADY_EXISTS, `A case named "${newName}" already exists`); + + const state = await probePath(oldPath); + if (state === 'absent') return fail(404, ApiErrorCode.NOT_FOUND, `Case "${name}" not found`); + // An alias-only rename never touches the folder, so an unreachable one does not block it. + if (state === 'unknown' && (!isLinked || body.renameFolder === true)) { + return fail(503, ApiErrorCode.OPERATION_FAILED, describeUnknownPath('Case folder', oldPath, { pastCap: true })); + } + + const moveFolder = !isLinked || body.renameFolder === true; + const newPath = isLinked ? (moveFolder ? siblingPath(oldPath, newName) : oldPath) : join(casesBase, newName); + 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. + // Remote sessions carry the remote path as their workingDir, so they are not this folder's. + const heldRoot = resolve(oldPath); + const open = moveFolder + ? Array.from(ctx.sessions.values()).filter((s) => { + if (s.remote) return false; + const dir = resolve(s.workingDir); + return dir === heldRoot || dir.startsWith(heldRoot + sep); + }) + : []; + if (open.some((s) => !canAccessOwned(user, s.owner))) { + return fail(403, ApiErrorCode.FORBIDDEN, "Another user's session has this case open"); + } + if (open.length > 0 && !body.stopSessions) { + return fail( + 409, + ApiErrorCode.CONFLICT, + `${open.length} live session${open.length === 1 ? ' has' : 's have'} this folder open. Stop them to rename it.`, + { requiresStop: true, sessions: open.map((s) => ({ id: s.id, name: s.name })) } + ); + } + + // Sessions really ended so far: an error after `stopSessions` still says how many are gone. + let stopped = 0; + try { + await withCaseLock(oldPath, newPath, async () => { + for (const s of open) { + await ctx.cleanupSession(s.id, true, 'case_rename'); + stopped++; + } + if (moveFolder) await moveCaseFolder(oldPath, newPath); + const originalLinks = isLinked ? await readLinkedCases() : undefined; + // The settings.json content before this rename wrote it, set only once a write is attempted (an + // absent file reads as {}, which must not be written back as a file that never existed). + let originalSettings: Record | undefined; + try { + // settings.json is server-wide and caseOrder/lastUsedCase name the admin's cases, so only the + // admin's rename touches it (a non-admin's own rename must not rewrite someone else's list). + // Written before linked-cases.json so a failure here leaves the registry untouched. + if (isAdmin(req)) { + const existing = await readJsonConfig>(SETTINGS_PATH, 'settings.json', {}); + const order = renameInOrder(existing.caseOrder, name, newName); + const last = existing.lastUsedCase === name ? newName : undefined; + if (order || last) { + originalSettings = existing; + await fs.writeFile( + SETTINGS_PATH, + JSON.stringify( + { ...existing, ...(order ? { caseOrder: order } : {}), ...(last ? { lastUsedCase: last } : {}) }, + null, + 2 + ) + ); + } + } + if (originalLinks) { + const links = { ...originalLinks }; + delete links[name]; + links[newName] = newPath; + try { + await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(links, null, 2)); + } catch (err) { + await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(originalLinks, null, 2)).catch(() => {}); + throw err; + } + } + } catch (err) { + // The folder moved but the registries did not follow: put settings.json and the folder back + // rather than leave a case whose name and folder disagree. + if (originalSettings) { + await fs.writeFile(SETTINGS_PATH, JSON.stringify(originalSettings, null, 2)).catch(() => {}); + } + if (moveFolder) { + const movedBack = await fs.rename(newPath, oldPath).then( + () => true, + () => false + ); + if (!movedBack) { + throw new Error(`${getErrorMessage(err)}. The folder could not be moved back and is now at ${newPath}.`); + } + } + throw err; + } + if (moveFolder) await moveClaudeHistory(oldPath, newPath); + // The owner's default session group is keyed by case name: carry it over, best effort. + const layoutOwner = ownerLayoutKey(ownerFor(req)); + try { + const { groupId } = await ctx.tabLayouts.caseDefaultGroup(layoutOwner, name); + if (groupId) await ctx.tabLayouts.setCaseDefaultGroup(layoutOwner, newName, groupId); + } catch { + /* the group is a convenience; the rename already happened */ + } + await ctx.tabLayouts.forgetCaseDefaultGroup(layoutOwner, name).catch(() => undefined); + }); + ctx.broadcast(SseEvent.CaseRenamed, { oldName: name, newName, oldPath, newPath, folderMoved: moveFolder }); + return { + success: true, + data: { oldName: name, newName, oldPath, newPath, folderMoved: moveFolder, stoppedSessions: open.length }, + }; + } catch (err) { + if (err instanceof CaseRenameError) { + const status = err.code === 'BUSY' ? 409 : err.code === 'EXISTS' ? 409 : err.code === 'LOCKED' ? 423 : 400; + const code = + err.code === 'EXISTS' + ? ApiErrorCode.ALREADY_EXISTS + : err.code === 'BUSY' + ? ApiErrorCode.CONFLICT + : ApiErrorCode.OPERATION_FAILED; + return fail(status, code, err.message, { renameCode: err.code, stoppedSessions: stopped }); + } + return fail(500, ApiErrorCode.OPERATION_FAILED, getErrorMessage(err), { stoppedSessions: stopped }); + } + }); + // ========== Reorder Cases ========== app.put('/api/cases/order', async (req, reply): Promise | undefined> => { diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 3982f4ea..bab07989 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -4,6 +4,7 @@ * auto-clear, auto-compact, image watcher, flicker filter, and logout. */ +import { claudeProjectDirName } from '../../case-rename.js'; import { FastifyInstance, type FastifyReply, type FastifyRequest } from 'fastify'; import { z } from 'zod'; import { join, dirname, extname, basename, resolve as resolvePath } from 'node:path'; @@ -5118,6 +5119,7 @@ export function registerSessionRoutes( if (!stat?.isDirectory()) return out; const workingDir = await decodeProjectKey(projDir); + const staleCwd = new Map(); const entries = await fs.readdir(projPath).catch(() => [] as string[]); for (const entry of entries) { @@ -5222,13 +5224,35 @@ export function registerSessionRoutes( worktreeRepo: headGit.worktreeRepo ?? tailGit.worktreeRepo, }; + // A case rename moves the project directory but leaves the old path in each transcript's + // cwd. When that cwd is gone and the decoded key is a real folder that encodes back to this + // very key (not the $HOME collapse of #265), the key is the truer answer. + let cwd = git.cwd; + if (cwd !== undefined && cwd !== workingDir) { + let stale = staleCwd.get(cwd); + if (stale === undefined) { + stale = + claudeProjectDirName(workingDir) === projDir && + (await fs.stat(cwd).then( + () => false, + () => true + )) && + (await fs.stat(workingDir).then( + (s) => s.isDirectory(), + () => false + )); + staleCwd.set(cwd, stale); + } + if (stale) cwd = undefined; + } + out.push({ sessionId, // The transcript's literal cwd beats decodeProjectKey's stat-walked guess, // which silently collapses to $HOME once the directory is gone (#265). // Absent cwd falls back to the old behaviour rather than inventing a path. - workingDir: git.cwd ?? workingDir, - workingDirExact: git.cwd !== undefined, + workingDir: cwd ?? workingDir, + workingDirExact: cwd !== undefined, projectKey: projDir, sizeBytes: fileStat.size, lastModified: fileStat.mtime.toISOString(), diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 86aa3f25..dff5ef49 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1992,6 +1992,21 @@ export const LinkCaseSchema = z.object({ path: safePathSchema, }); +/** POST /api/cases/:name/rename */ +export const RenameCaseSchema = z + .object({ + newName: z + .string() + .min(1) + .max(100) + .regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.'), + /** Linked cases only: also rename the real folder on disk (a case under the cases directory always moves). */ + renameFolder: z.boolean().optional(), + /** Stop the live sessions that have the folder open, instead of refusing with the list of them. */ + stopSessions: z.boolean().optional(), + }) + .strict(); + /** PUT /api/cases/order */ export const CaseOrderSchema = z.object({ order: z.array(z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format')), diff --git a/src/web/sse-events.ts b/src/web/sse-events.ts index 8e79c856..434bfad9 100644 --- a/src/web/sse-events.ts +++ b/src/web/sse-events.ts @@ -32,7 +32,7 @@ * - **Custom Model Endpoint Profiles** (1): swapped-out (a session's model got evicted by another session on the same llama-swap endpoint) * - **Orchestrator** (12): stateChanged, planProgress, planReady, phase*, verification, task*, completed, error * - **Clipboard** (1): write - * - **Cases** (4): created, linked, deleted, order-changed + * - **Cases** (5): created, linked, deleted, renamed, order-changed * - **Docker cases** (8): exportComplete/Failed, importComplete, imageBuild*, containerRecreated * - **Multi-user** (3): admin:usersChanged, auth:passwordChangeRequired, session:orderChanged * - **Web tabs** (2): webview:changed, tab:layoutChanged @@ -458,6 +458,8 @@ export const CaseCreated = 'case:created' as const; export const CaseLinked = 'case:linked' as const; /** Case deleted or unlinked. */ export const CaseDeleted = 'case:deleted' as const; +/** Case renamed (name and folder). */ +export const CaseRenamed = 'case:renamed' as const; /** Case ordering changed. */ export const CaseOrderChanged = 'case:order-changed' as const; @@ -702,6 +704,7 @@ export const SseEvent = { CaseCreated, CaseLinked, CaseDeleted, + CaseRenamed, CaseOrderChanged, // Docker cases diff --git a/test/case-rename-ui.test.ts b/test/case-rename-ui.test.ts new file mode 100644 index 00000000..4abee9c3 --- /dev/null +++ b/test/case-rename-ui.test.ts @@ -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; + 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(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 }> = []; + const toasts: string[] = []; + const store = new Map([['caseSettings_alpha', '{"agentTeams":true}']]); + const queue = [...opts.responses]; + const ids = [ + 'renameCaseModal', + 'renameCaseInput', + 'renameCaseLabel', + 'renameCaseFolderRow', + 'renameCaseFolder', + 'renameCaseFolderHint', + 'renameCaseNote', + 'renameCaseError', + 'renameCaseSubmit', + 'quickStartCase', + ]; + const els: Record = 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; + 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); + }); +}); diff --git a/test/case-rename.test.ts b/test/case-rename.test.ts new file mode 100644 index 00000000..09dff6b9 --- /dev/null +++ b/test/case-rename.test.ts @@ -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'); + }); +}); diff --git a/test/routes/case-rename-routes.test.ts b/test/routes/case-rename-routes.test.ts new file mode 100644 index 00000000..780f4635 --- /dev/null +++ b/test/routes/case-rename-routes.test.ts @@ -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 => (existsSync(LINKED()) ? JSON.parse(readFileSync(LINKED(), 'utf8')) : {}); +const settings = (): Record => JSON.parse(readFileSync(SETTINGS_PATH, 'utf8')); +const rename = (app: Awaited>['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).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).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).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).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).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).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); + }); +}); diff --git a/test/routes/session-routes.test.ts b/test/routes/session-routes.test.ts index 1d6eaf8b..aa03f208 100644 --- a/test/routes/session-routes.test.ts +++ b/test/routes/session-routes.test.ts @@ -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