mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-05 15:09:33 +01:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5929584ea4 |
No files matched your search
@@ -183,7 +183,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 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 `<claude config>/projects/<old>` 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 `<claude config>/projects/<old>` 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. ⚠️ Every write to `linked-cases.json` (link, unlink, rename, and a create or clone into a custom folder) goes through `mutateLinkedCases()` (`src/web/linked-cases-writer.ts`: serialized, temp file + rename, and a file that does not parse is refused, never read as `{}`), and a check that must still hold when the entry is written (the name is free) runs INSIDE its callback: two clones that claimed one name used to both pass a re-read made outside it and both answer success. Pinned by `test/linked-cases-writer.test.ts`.
|
||||
|
||||
**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; the one exception is a recipe's `linkFromRealHome` (deepseek's `profiles`, `sessions`, `settings.yaml`), which symlinks those named entries back out of the real home so they survive the redirect. ⚠️ 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)
|
||||
|
||||
|
||||
@@ -474,7 +474,7 @@ Tests: `test/file-preview-markdown.test.ts` (jsdom-in-vm, pins every rule above)
|
||||
|
||||
### Clone a repository as a case
|
||||
|
||||
**Clone Repo tab** (issue #236, proposed by @DodgyBadger): `POST /api/cases/clone` clones a public repository into the caller's case space and registers it as a normal local case; `POST /api/cases/clone-preflight` answers "can this be cloned anonymously, and what refs does it have?" while the user is still typing. Core in `src/git-clone.ts`, split into a PURE half (URL parse, argv/env, `ls-remote` parse, stderr classification) and a thin IO half (`probeGitRemote`, `cloneRepository`). An optional `path` (the **Clone into a custom folder** checkbox) is the folder the repository is cloned INTO, exactly as given (the case name adds no subfolder), and it registers a LINKED case, the same way `POST /api/cases` with a `path` does: it is judged by `prepareNewCasePath()` (`web/case-path.ts`: blocked trees, symlinks, parent must exist), is admin-only in multi-user mode (it writes an absolute path into the shared linked-cases registry), and must be empty or not exist yet. An existing empty folder is cloned into directly (`CloneOptions.intoEmptyDir`: no temporary sibling and no rename, so a mount point works; a failed attempt empties it again but never removes it); a folder with anything in it is refused. Deleting such a case unlinks it and never removes the files. If the registry write fails after git finished, the folder is kept and the error says where it is.
|
||||
**Clone Repo tab** (issue #236, proposed by @DodgyBadger): `POST /api/cases/clone` clones a public repository into the caller's case space and registers it as a normal local case; `POST /api/cases/clone-preflight` answers "can this be cloned anonymously, and what refs does it have?" while the user is still typing. Core in `src/git-clone.ts`, split into a PURE half (URL parse, argv/env, `ls-remote` parse, stderr classification) and a thin IO half (`probeGitRemote`, `cloneRepository`). An optional `path` (the **Clone into a custom folder** checkbox) is the folder the repository is cloned INTO, exactly as given (the case name adds no subfolder), and it registers a LINKED case, the same way `POST /api/cases` with a `path` does: it is judged by `prepareNewCasePath()` (`web/case-path.ts`: blocked trees, symlinks, parent must exist), is admin-only in multi-user mode (it writes an absolute path into the shared linked-cases registry), and must be empty or not exist yet. An existing empty folder is cloned into directly (`CloneOptions.intoEmptyDir`: no temporary sibling and no rename, so a mount point works; a failed attempt empties it again but never removes it); a folder with anything in it is refused. Deleting such a case unlinks it and never removes the files. If the registry write fails after git finished, the folder is kept and the error says where it is. The name is checked again inside `mutateLinkedCases()` when the entry is written, so of two clones that claim one name only the first to finish links it; the other answers 409 `ALREADY_EXISTS` and keeps its folder.
|
||||
|
||||
- ⚠️ **The URL is a code-execution surface, which is why it is parsed rather than forwarded.** `ext::sh -c <cmd>` makes git run an arbitrary command as its transport, and ANY `<name>::<payload>` dispatches to a `git-remote-<name>` helper, so every `::` form is refused outright. A repository starting with `-` is read by git as a flag; that is rejected AND every spawn puts `--` before the operands, because either defence alone is one edit away from being a hole. Spawns are argv arrays, never a shell (unlike `remote-hosts.ts`, which does build a shell line and must `shellescape`). The Zod schema deliberately only length-bounds `repository` — a weaker regex duplicate of `parseGitRepositoryUrl` would be the copy that drifts.
|
||||
- ⚠️ **Non-interactive or it hangs the request.** The clone is synchronous by design (no job store, no polling, no cancellation surface), so an invisible credential prompt would pin an open HTTP request until the timeout. `gitNonInteractiveEnv()` closes all four prompt paths at once: `GIT_TERMINAL_PROMPT=0`, empty `GIT_ASKPASS`/`SSH_ASKPASS` + `SSH_ASKPASS_REQUIRE=never` + empty `DISPLAY`, `GCM_INTERACTIVE=never`, and `ssh -oBatchMode=yes`. `HOME`/`PATH` are inherited on purpose — a user whose own agent or credential helper already works keeps working (so a private repo may well clone; Codeman just never collects or stores credentials, and refuses a `user:password@` URL). ⚠️ In multi-user mode a NON-ADMIN's clone and preflight run with `git -c credential.helper=` (`cloneWithoutCredentialHelpers`), because the helpers belong to the one server account every user shares; admins and single-user mode keep them. The Docker server image relies on exactly this: when built with the opt-in `CODEMAN_INSTALL_GH`/`CODEMAN_INSTALL_AZ` args it configures `gh` (github.com) and an `az`-backed helper (`docker/git-credential-azure-cli`, Azure DevOps) in its SYSTEM gitconfig, both silent until the user signs the CLI in, so a private clone there still fails fast rather than prompting.
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* @fileoverview The writer of the linked-cases registry (`linked-cases.json` in the data dir): case
|
||||
* name to the absolute folder it stands for.
|
||||
*
|
||||
* Every read-modify-write of that file goes through `mutateLinkedCases()`, the same shape as
|
||||
* `mutateSettingsFile()` (settings-writer.ts). The one direct writer is config-backup restore
|
||||
* (`restoreConfigBackup()`), which must work on a file that does not parse. Readers keep reading the
|
||||
* file directly; a write is atomic, so they never see half of one.
|
||||
*
|
||||
* - **Serialized.** Mutations run one at a time on a single promise chain, each one reading, changing
|
||||
* and writing before the next starts. Link, unlink, rename, a create in a custom folder and a clone
|
||||
* into one each used to read the file, check it and write it back on their own. Two clones that
|
||||
* claimed one name and finished together both passed the check, both answered success, and the
|
||||
* later write replaced the earlier one's entry. Any two writers could lose an entry the same way.
|
||||
* A check that must hold when the entry is written belongs inside `fn`: throwing there rejects that
|
||||
* caller only, and nothing is written.
|
||||
* - **Atomic.** Each write goes to its own temp file beside the target and is renamed over it, so a
|
||||
* reader never sees a half-written file (quick-start would read one as an empty registry and resolve
|
||||
* a linked case to the cases dir instead), and a failed write leaves the previous file in place.
|
||||
* - **Refuses a file it must not trust.** Only a missing (or empty) file starts from `{}`. A file that
|
||||
* cannot be read or is not a JSON object is left alone and the write throws
|
||||
* `LinkedCasesWriteRefusedError`: read as `{}`, the next link would write back its own entry alone
|
||||
* and unlink every other case.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { dataPath } from '../config/instance.js';
|
||||
import { getErrorMessage } from '../types.js';
|
||||
|
||||
export type LinkedCases = Record<string, string>;
|
||||
|
||||
/** Resolved at call time, like every other `dataPath()` caller that tests point at a temp HOME. */
|
||||
export function linkedCasesPath(): string {
|
||||
return dataPath('linked-cases.json');
|
||||
}
|
||||
|
||||
/** A write refused because the existing registry must not be overwritten. The message is user-facing. */
|
||||
export class LinkedCasesWriteRefusedError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'LinkedCasesWriteRefusedError';
|
||||
}
|
||||
}
|
||||
|
||||
async function readLinkedCasesForWrite(path: string): Promise<LinkedCases> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(path, 'utf-8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {};
|
||||
throw new LinkedCasesWriteRefusedError(
|
||||
`Cannot read ${path} (${(err as Error).message}), so the linked cases were not changed.`
|
||||
);
|
||||
}
|
||||
if (raw.trim() === '') return {};
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
parsed = undefined;
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
throw new LinkedCasesWriteRefusedError(
|
||||
`${path} is not a valid JSON object, so the linked cases were not changed (saving would have unlinked every other case). Fix or delete the file, or restore it from a config backup, then try again.`
|
||||
);
|
||||
}
|
||||
return parsed as LinkedCases;
|
||||
}
|
||||
|
||||
async function writeLinkedCasesFile(path: string, linked: LinkedCases): Promise<void> {
|
||||
// Through a symlink, as the plain writeFile this replaces did: the rename lands on the real file.
|
||||
const target = await fs.realpath(path).catch(() => path);
|
||||
await fs.mkdir(dirname(target), { recursive: true });
|
||||
// A plain writeFile kept the file's permissions; a rename would not, so the temp file takes them.
|
||||
const mode = await fs.stat(target).then(
|
||||
(s) => s.mode & 0o777,
|
||||
() => undefined
|
||||
);
|
||||
const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(tmp, JSON.stringify(linked, null, 2), mode === undefined ? undefined : { mode });
|
||||
// The creation mode is masked by the umask; the existing file's bits are put back as they were.
|
||||
if (mode !== undefined) await fs.chmod(tmp, mode);
|
||||
await fs.rename(tmp, target);
|
||||
} catch (err) {
|
||||
await fs.rm(tmp, { force: true }).catch(() => {});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
let mutationChain: Promise<unknown> = Promise.resolve();
|
||||
|
||||
/**
|
||||
* Run one change to the linked-cases registry. `fn` receives a copy of the current registry and
|
||||
* returns the registry to write, or `undefined` to write nothing; it may throw to refuse the change
|
||||
* (its error rejects this call as thrown). Resolves with the registry before and after the change
|
||||
* (`after` is `undefined` when nothing was written). A refused or failed mutation rejects its own
|
||||
* caller only; the chain keeps going.
|
||||
*/
|
||||
export function mutateLinkedCases(
|
||||
fn: (current: LinkedCases) => LinkedCases | undefined
|
||||
): Promise<{ before: LinkedCases; after: LinkedCases | undefined }> {
|
||||
const run = mutationChain.then(async () => {
|
||||
const path = linkedCasesPath();
|
||||
const before = await readLinkedCasesForWrite(path).catch((err: unknown) => {
|
||||
console.warn(`[linked-cases] write refused: ${getErrorMessage(err)}`);
|
||||
throw err;
|
||||
});
|
||||
const after = fn({ ...before });
|
||||
if (after) await writeLinkedCasesFile(path, after);
|
||||
return { before, after };
|
||||
});
|
||||
mutationChain = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
@@ -86,6 +86,7 @@ import {
|
||||
SettingsWriteRefusedError,
|
||||
settingsWriteErrorResponse,
|
||||
} from '../settings-writer.js';
|
||||
import { LinkedCasesWriteRefusedError, linkedCasesPath, mutateLinkedCases } from '../linked-cases-writer.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import type { AuthUser } from '../../types.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
@@ -126,7 +127,6 @@ import {
|
||||
writeRemoteHosts,
|
||||
} from '../../remote-hosts.js';
|
||||
|
||||
const LINKED_CASES_FILE = dataPath('linked-cases.json');
|
||||
const CODEMAN_CONFIG_DIR = getDataDir();
|
||||
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
|
||||
const DOCKER_EXPORTS_DIR = dataPath('docker-exports');
|
||||
@@ -212,11 +212,21 @@ function agentCreatedInfo(marker: AgentCaseMarker): NonNullable<CaseInfo['agentC
|
||||
};
|
||||
}
|
||||
|
||||
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
|
||||
/**
|
||||
* Read and parse linked-cases.json, returning empty object on missing/invalid file. For reads only:
|
||||
* every write goes through `mutateLinkedCases()`, which re-reads the file inside its own critical
|
||||
* section and refuses one that does not parse.
|
||||
*/
|
||||
async function readLinkedCases(): Promise<Record<string, string>> {
|
||||
return readJsonConfig<Record<string, string>>(LINKED_CASES_FILE, 'linked cases', {});
|
||||
return readJsonConfig<Record<string, string>>(linkedCasesPath(), 'linked cases', {});
|
||||
}
|
||||
|
||||
/** A conflict thrown from inside a `mutateLinkedCases()` callback: answered 409, nothing written. */
|
||||
function linkedConflict(message: string): Error {
|
||||
return Object.assign(new Error(message), { conflict: true });
|
||||
}
|
||||
const isLinkedConflict = (err: unknown): boolean => (err as { conflict?: boolean }).conflict === true;
|
||||
|
||||
/**
|
||||
* Resolve a case name to its directory path, checking linked cases first, then the
|
||||
* user's case space (per-user in multi-user mode, the shared CASES_DIR otherwise).
|
||||
@@ -553,15 +563,11 @@ export function registerCaseRoutes(
|
||||
made.push(join(casePath, '.claude')); // before the write, so a half-written one is undone too
|
||||
await writeHooksConfig(casePath);
|
||||
|
||||
const codemanDir = getDataDir();
|
||||
if (!existsSync(codemanDir)) mkdirSync(codemanDir, { recursive: true });
|
||||
// Re-read right before writing, so a case linked since the check above is not dropped. This only
|
||||
// narrows the window: like POST /api/cases/link, the registry write is not serialized, and two
|
||||
// requests that both read before either writes can still lose one entry.
|
||||
const fresh = await readLinkedCases();
|
||||
if (fresh[name]) throw Object.assign(new Error(`Case "${name}" was just linked`), { conflict: true });
|
||||
fresh[name] = casePath;
|
||||
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(fresh, null, 2));
|
||||
// The check above ran before the scaffold; a case linked since then wins and this call is undone.
|
||||
await mutateLinkedCases((current) => {
|
||||
if (current[name]) throw linkedConflict(`Case "${name}" was just linked`);
|
||||
return { ...current, [name]: casePath };
|
||||
});
|
||||
|
||||
ctx.broadcast(SseEvent.CaseCreated, { name, path: casePath });
|
||||
return { success: true, data: { case: { name, path: casePath } } };
|
||||
@@ -569,10 +575,14 @@ export function registerCaseRoutes(
|
||||
// Undo only what this call made. The whole folder if we created it, otherwise the scaffold
|
||||
// entries inside the empty folder the user picked; never anything else.
|
||||
for (const p of made.reverse()) await fs.rm(p, { recursive: true, force: true }).catch(() => undefined);
|
||||
if ((err as { conflict?: boolean }).conflict) {
|
||||
if (isLinkedConflict(err)) {
|
||||
reply.code(409);
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, getErrorMessage(err));
|
||||
}
|
||||
if (err instanceof LinkedCasesWriteRefusedError) {
|
||||
reply.code(409);
|
||||
return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
|
||||
}
|
||||
reply.code(500);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
@@ -890,20 +900,20 @@ export function registerCaseRoutes(
|
||||
}
|
||||
|
||||
if (customPath !== undefined) {
|
||||
// Re-read right before writing so a case linked while git ran is not dropped. Like
|
||||
// POST /api/cases/link the write is not serialized, so this only narrows the window.
|
||||
// The name was checked before git ran, which can take minutes. Checked again where the entry is
|
||||
// written, so of two clones that claimed one name only the first to finish links it.
|
||||
try {
|
||||
const fresh = await readLinkedCases();
|
||||
if (fresh[name])
|
||||
throw Object.assign(new Error(`Case "${name}" was linked while the clone ran`), { conflict: true });
|
||||
fresh[name] = casePath;
|
||||
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(fresh, null, 2));
|
||||
await mutateLinkedCases((current) => {
|
||||
if (current[name]) throw linkedConflict(`Case "${name}" was linked while the clone ran`);
|
||||
return { ...current, [name]: casePath };
|
||||
});
|
||||
} catch (err) {
|
||||
// The clone itself succeeded: keep the folder (the user waited for it) and say where it is.
|
||||
const conflict = (err as { conflict?: boolean }).conflict;
|
||||
reply.code(conflict ? 409 : 500);
|
||||
const conflict = isLinkedConflict(err);
|
||||
const refused = err instanceof LinkedCasesWriteRefusedError;
|
||||
reply.code(conflict || refused ? 409 : 500);
|
||||
return createErrorResponse(
|
||||
conflict ? ApiErrorCode.ALREADY_EXISTS : ApiErrorCode.OPERATION_FAILED,
|
||||
conflict ? ApiErrorCode.ALREADY_EXISTS : refused ? ApiErrorCode.CONFLICT : ApiErrorCode.OPERATION_FAILED,
|
||||
`Cloned into ${casePath} but could not register the case (${getErrorMessage(err)}). Use Link Existing to add it.`
|
||||
);
|
||||
}
|
||||
@@ -1713,28 +1723,17 @@ export function registerCaseRoutes(
|
||||
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'A case with this name already exists in codeman-cases.');
|
||||
}
|
||||
|
||||
// Load existing linked cases
|
||||
const linkedCases = await readLinkedCases();
|
||||
|
||||
// Check if name is already linked
|
||||
if (linkedCases[name]) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.ALREADY_EXISTS,
|
||||
`Case "${name}" is already linked to ${linkedCases[name]}`
|
||||
);
|
||||
}
|
||||
|
||||
// Save the linked case
|
||||
linkedCases[name] = expandedPath;
|
||||
// The name check and the write are one step, so two links of one name cannot both succeed.
|
||||
try {
|
||||
const codemanDir = getDataDir();
|
||||
if (!existsSync(codemanDir)) {
|
||||
mkdirSync(codemanDir, { recursive: true });
|
||||
}
|
||||
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(linkedCases, null, 2));
|
||||
await mutateLinkedCases((current) => {
|
||||
if (current[name]) throw linkedConflict(`Case "${name}" is already linked to ${current[name]}`);
|
||||
return { ...current, [name]: expandedPath };
|
||||
});
|
||||
ctx.broadcast(SseEvent.CaseLinked, { name, path: expandedPath });
|
||||
return { success: true, data: { case: { name, path: expandedPath } } };
|
||||
} catch (err) {
|
||||
if (isLinkedConflict(err)) return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, getErrorMessage(err));
|
||||
if (err instanceof LinkedCasesWriteRefusedError) return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
});
|
||||
@@ -1815,13 +1814,19 @@ export function registerCaseRoutes(
|
||||
// must not unlink one either; skip so control falls through to their local delete.
|
||||
const linkedCases = await readLinkedCases();
|
||||
if (linkedCases[name] && (!isMultiUserMode() || isAdmin(req))) {
|
||||
delete linkedCases[name];
|
||||
try {
|
||||
await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(linkedCases, null, 2));
|
||||
// Removes this one entry from the registry as it is now, keeping any linked since the read above.
|
||||
await mutateLinkedCases((current) => {
|
||||
if (!Object.hasOwn(current, name)) return undefined;
|
||||
const next = { ...current };
|
||||
delete next[name];
|
||||
return next;
|
||||
});
|
||||
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'unlinked' });
|
||||
await forgetCaseDefault();
|
||||
return { success: true, data: { name } };
|
||||
} catch (err) {
|
||||
if (err instanceof LinkedCasesWriteRefusedError) return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
}
|
||||
@@ -2007,7 +2012,6 @@ export function registerCaseRoutes(
|
||||
stopped++;
|
||||
}
|
||||
if (moveFolder) await moveCaseFolder(oldPath, newPath);
|
||||
const originalLinks = isLinked ? await readLinkedCases() : undefined;
|
||||
// The settings.json values this rename replaced, set only once it wrote them. A failure puts back
|
||||
// these keys alone, so a settings save that landed in between is kept.
|
||||
let settingsUndo: Record<string, unknown> | undefined;
|
||||
@@ -2027,16 +2031,19 @@ export function registerCaseRoutes(
|
||||
settingsUndo = Object.fromEntries(keys.map((k) => [k, before[k]]));
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
if (isLinked) {
|
||||
// Checked again where the entry is written: the checks above ran before the sessions were
|
||||
// stopped and the folder moved. A failed write leaves the registry as it was.
|
||||
await mutateLinkedCases((current) => {
|
||||
if (current[newName]) throw new CaseRenameError('EXISTS', `A case named "${newName}" already exists`);
|
||||
if (current[name] !== oldPath) {
|
||||
throw new CaseRenameError('BUSY', `Case "${name}" was changed while it was being renamed`);
|
||||
}
|
||||
const links = { ...current };
|
||||
delete links[name];
|
||||
links[newName] = newPath;
|
||||
return links;
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// The folder moved but the registries did not follow: put settings.json and the folder back
|
||||
@@ -2081,6 +2088,9 @@ export function registerCaseRoutes(
|
||||
: ApiErrorCode.OPERATION_FAILED;
|
||||
return fail(status, code, err.message, { renameCode: err.code, stoppedSessions: stopped });
|
||||
}
|
||||
if (err instanceof LinkedCasesWriteRefusedError) {
|
||||
return fail(409, ApiErrorCode.CONFLICT, err.message, { stoppedSessions: stopped });
|
||||
}
|
||||
return fail(500, ApiErrorCode.OPERATION_FAILED, getErrorMessage(err), { stoppedSessions: stopped });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* @fileoverview Every writer of the linked-cases registry is serialized, atomic, and refuses to
|
||||
* overwrite a file that does not parse (`mutateLinkedCases()`, src/web/linked-cases-writer.ts). Real
|
||||
* filesystem under test/setup.ts's temp HOME. Port: N/A (app.inject()).
|
||||
*
|
||||
* The CI flake this fixes was "a name linked while the clone ran is a 409" in
|
||||
* test/routes/case-clone-custom-folder.test.ts: two clones claiming one name both re-read the
|
||||
* registry before either wrote it, so both answered success and the later write replaced the
|
||||
* earlier entry. Writes to the registry (and its temp files) are slowed down here, so two writers
|
||||
* that both read before either writes lose an entry every time instead of once in a while.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'node:fs/promises';
|
||||
import {
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
realpathSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import { createRouteTestHarness } from './routes/_route-test-utils.js';
|
||||
import { registerCaseRoutes } from '../src/web/routes/case-routes.js';
|
||||
import { LinkedCasesWriteRefusedError, linkedCasesPath, mutateLinkedCases } from '../src/web/linked-cases-writer.js';
|
||||
import { isSandboxedHome, safeRmHomeTree } from './mocks/index.js';
|
||||
|
||||
const LINKED = () => linkedCasesPath();
|
||||
const linked = (): Record<string, string> => JSON.parse(readFileSync(LINKED(), 'utf8'));
|
||||
/** Temp files the writer left beside the registry (there must never be any). */
|
||||
const leftovers = () =>
|
||||
readdirSync(dirname(LINKED())).filter((n) => n.startsWith(`${basename(LINKED())}.`) && n.endsWith('.tmp'));
|
||||
/** The bytes of a registry torn by two overlapping truncate-then-write calls. */
|
||||
const TORN = '{\n "kept": "/srv/kept"\n}' + 'ept-2"\n}';
|
||||
|
||||
/** How long every write to the registry (or a temp file beside it) is held before it starts. */
|
||||
const SLOW_WRITE_MS = 60;
|
||||
const realWriteFile = fs.writeFile.bind(fs);
|
||||
|
||||
let workDir = '';
|
||||
|
||||
beforeEach(() => {
|
||||
if (!isSandboxedHome()) {
|
||||
throw new Error('linked-cases-writer.test.ts rewrites the linked-cases registry: run it via npm test');
|
||||
}
|
||||
safeRmHomeTree(LINKED());
|
||||
mkdirSync(dirname(LINKED()), { recursive: true });
|
||||
workDir = realpathSync(mkdtempSync(join(homedir(), 'linked-cases-writer-')));
|
||||
vi.spyOn(fs, 'writeFile').mockImplementation((async (file: unknown, data: unknown, options?: unknown) => {
|
||||
if (typeof file === 'string' && file.startsWith(LINKED())) {
|
||||
await new Promise((resolve) => setTimeout(resolve, SLOW_WRITE_MS));
|
||||
}
|
||||
return realWriteFile(file as string, data as string, options as undefined);
|
||||
}) as typeof fs.writeFile);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (workDir) safeRmHomeTree(workDir);
|
||||
workDir = '';
|
||||
if (isSandboxedHome()) safeRmHomeTree(LINKED());
|
||||
});
|
||||
|
||||
describe('mutateLinkedCases()', () => {
|
||||
it('runs concurrent mutations one at a time, so none loses another one’s entry', async () => {
|
||||
writeFileSync(LINKED(), JSON.stringify({ kept: '/srv/kept' }));
|
||||
await Promise.all(
|
||||
Array.from({ length: 8 }, (_, i) => mutateLinkedCases((current) => ({ ...current, [`c${i}`]: `/srv/c${i}` })))
|
||||
);
|
||||
expect(Object.keys(linked()).sort()).toEqual(['c0', 'c1', 'c2', 'c3', 'c4', 'c5', 'c6', 'c7', 'kept']);
|
||||
expect(leftovers()).toEqual([]);
|
||||
});
|
||||
|
||||
it('a check that throws inside the mutation rejects that caller only and writes nothing', async () => {
|
||||
const claim = (name: string, path: string) =>
|
||||
mutateLinkedCases((current) => {
|
||||
if (current[name]) throw Object.assign(new Error(`${name} is taken`), { conflict: true });
|
||||
return { ...current, [name]: path };
|
||||
});
|
||||
const results = await Promise.allSettled([
|
||||
claim('twin', '/srv/a'),
|
||||
claim('twin', '/srv/b'),
|
||||
claim('solo', '/srv/s'),
|
||||
]);
|
||||
expect(results.map((r) => r.status)).toEqual(['fulfilled', 'rejected', 'fulfilled']);
|
||||
expect((results[1] as PromiseRejectedResult).reason.message).toBe('twin is taken');
|
||||
expect(linked()).toEqual({ twin: '/srv/a', solo: '/srv/s' });
|
||||
});
|
||||
|
||||
it('starts from an empty registry when the file is missing or empty, and writes nothing for undefined', async () => {
|
||||
const { before, after } = await mutateLinkedCases((current) => ({ ...current, a: '/srv/a' }));
|
||||
expect(before).toEqual({});
|
||||
expect(after).toEqual({ a: '/srv/a' });
|
||||
writeFileSync(LINKED(), ' \n');
|
||||
await mutateLinkedCases((current) => ({ ...current, b: '/srv/b' }));
|
||||
expect(linked()).toEqual({ b: '/srv/b' });
|
||||
const untouched = readFileSync(LINKED(), 'utf8');
|
||||
expect((await mutateLinkedCases(() => undefined)).after).toBeUndefined();
|
||||
expect(readFileSync(LINKED(), 'utf8')).toBe(untouched);
|
||||
});
|
||||
|
||||
it('refuses a registry that is not a JSON object and leaves it byte for byte', async () => {
|
||||
for (const content of [TORN, '[]', 'null', '"text"']) {
|
||||
writeFileSync(LINKED(), content);
|
||||
const fn = vi.fn((current: Record<string, string>) => ({ ...current, a: '/srv/a' }));
|
||||
await expect(mutateLinkedCases(fn)).rejects.toBeInstanceOf(LinkedCasesWriteRefusedError);
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
expect(readFileSync(LINKED(), 'utf8')).toBe(content);
|
||||
}
|
||||
// The chain keeps going after a refusal.
|
||||
writeFileSync(LINKED(), '{}');
|
||||
await mutateLinkedCases((current) => ({ ...current, a: '/srv/a' }));
|
||||
expect(linked()).toEqual({ a: '/srv/a' });
|
||||
});
|
||||
|
||||
it('leaves the previous registry and no temp file when the write fails', async () => {
|
||||
writeFileSync(LINKED(), JSON.stringify({ kept: '/srv/kept' }));
|
||||
const realRename = fs.rename.bind(fs);
|
||||
vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => {
|
||||
if (to === LINKED()) throw Object.assign(new Error('no space left on device'), { code: 'ENOSPC' });
|
||||
return realRename(from, to);
|
||||
});
|
||||
await expect(mutateLinkedCases((current) => ({ ...current, a: '/srv/a' }))).rejects.toThrow('no space left');
|
||||
expect(linked()).toEqual({ kept: '/srv/kept' });
|
||||
expect(leftovers()).toEqual([]);
|
||||
});
|
||||
|
||||
it('writes through a symlinked registry onto its target and keeps the file’s permissions', async () => {
|
||||
const real = join(workDir, 'linked-cases.json');
|
||||
writeFileSync(real, JSON.stringify({ kept: '/srv/kept' }), { mode: 0o600 });
|
||||
safeRmHomeTree(LINKED());
|
||||
symlinkSync(real, LINKED());
|
||||
await mutateLinkedCases((current) => ({ ...current, a: '/srv/a' }));
|
||||
expect(lstatSync(LINKED()).isSymbolicLink()).toBe(true);
|
||||
expect(readlinkSync(LINKED())).toBe(real);
|
||||
expect(JSON.parse(readFileSync(real, 'utf8'))).toEqual({ kept: '/srv/kept', a: '/srv/a' });
|
||||
expect(statSync(real).mode & 0o777).toBe(0o600);
|
||||
expect(readdirSync(workDir).filter((n) => n.endsWith('.tmp'))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the case routes share the writer', () => {
|
||||
const folder = (name: string) => {
|
||||
const dir = join(workDir, name);
|
||||
mkdirSync(dir);
|
||||
return dir;
|
||||
};
|
||||
|
||||
it('two links of different names at once both survive', async () => {
|
||||
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
||||
const [a, b] = await Promise.all([
|
||||
app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'one', path: folder('one') } }),
|
||||
app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'two', path: folder('two') } }),
|
||||
]);
|
||||
expect([a.json().success, b.json().success]).toEqual([true, true]);
|
||||
expect(linked()).toEqual({ one: join(workDir, 'one'), two: join(workDir, 'two') });
|
||||
});
|
||||
|
||||
it('two links of one name at once: exactly one wins and the other is told the name is taken', async () => {
|
||||
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
||||
const [a, b] = await Promise.all([
|
||||
app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'twin', path: folder('twin-a') } }),
|
||||
app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'twin', path: folder('twin-b') } }),
|
||||
]);
|
||||
expect([a.json().success, b.json().success].filter(Boolean)).toHaveLength(1);
|
||||
const loser = a.json().success ? b : a;
|
||||
expect(loser.json().errorCode).toBe('ALREADY_EXISTS');
|
||||
expect(Object.keys(linked())).toEqual(['twin']);
|
||||
});
|
||||
|
||||
it('an unlink at the same time as a link keeps the new link', async () => {
|
||||
writeFileSync(LINKED(), JSON.stringify({ old: folder('old') }));
|
||||
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
||||
const [unlink, link] = await Promise.all([
|
||||
app.inject({ method: 'DELETE', url: '/api/cases/old' }),
|
||||
app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'fresh', path: folder('fresh') } }),
|
||||
]);
|
||||
expect([unlink.json().success, link.json().success]).toEqual([true, true]);
|
||||
expect(linked()).toEqual({ fresh: join(workDir, 'fresh') });
|
||||
expect(existsSync(join(workDir, 'old'))).toBe(true);
|
||||
});
|
||||
|
||||
it('a link refuses a registry that does not parse instead of replacing it', async () => {
|
||||
writeFileSync(LINKED(), TORN);
|
||||
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
||||
const res = await app.inject({ method: 'POST', url: '/api/cases/link', payload: { name: 'n', path: folder('n') } });
|
||||
expect(res.json()).toMatchObject({ success: false, errorCode: 'CONFLICT' });
|
||||
expect(res.json().error).toContain('is not a valid JSON object');
|
||||
expect(readFileSync(LINKED(), 'utf8')).toBe(TORN);
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@
|
||||
* to start there. Each test's folders sit in a fresh mkdtemp dir, and everything is removed through
|
||||
* safeRmHomeTree, so a run can only ever delete what it created.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import { basename, join } from 'node:path';
|
||||
import { createRouteTestHarness } from './_route-test-utils.js';
|
||||
@@ -40,7 +41,7 @@ beforeEach(() => {
|
||||
if (!sandboxed()) {
|
||||
throw new Error('case-custom-path-routes.test.ts deletes the linked-cases registry: run it via npm test');
|
||||
}
|
||||
// Recursive: the rollback tests turn the registry into a directory.
|
||||
// Recursive, so a test that left anything at the registry's path cannot leak into the next one.
|
||||
safeRmHomeTree(LINKED());
|
||||
// Resolved, because the server answers with the symlink-resolved folder (macOS temp is under /private).
|
||||
workDir = realpathSync(mkdtempSync(join(homedir(), 'case-custom-path-')));
|
||||
@@ -134,20 +135,36 @@ describe('POST /api/cases with a custom path', () => {
|
||||
|
||||
it('undoes what it created when registering fails (a new folder is removed entirely)', async () => {
|
||||
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
||||
mkdirSync(LINKED(), { recursive: true }); // writeFile onto a directory fails
|
||||
const res = await create(app, { name: 'doomed', path: join(work(), 'doomed') });
|
||||
// The registry is replaced by renaming a temp file over it: fail that rename.
|
||||
const realRename = fs.rename;
|
||||
const spy = vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => {
|
||||
if (to === LINKED()) throw Object.assign(new Error('permission denied'), { code: 'EACCES' });
|
||||
return realRename(from, to);
|
||||
});
|
||||
let res;
|
||||
try {
|
||||
res = await create(app, { name: 'doomed', path: join(work(), 'doomed') });
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(existsSync(join(work(), 'doomed'))).toBe(false);
|
||||
expect(linked()).toEqual({});
|
||||
});
|
||||
|
||||
it('undoes only the scaffold inside an empty folder the user picked, leaving the folder', async () => {
|
||||
it('undoes only the scaffold inside an empty folder the user picked when the registry is refused', async () => {
|
||||
const { app } = await createRouteTestHarness(registerCaseRoutes);
|
||||
mkdirSync(join(work(), 'picked'));
|
||||
mkdirSync(LINKED(), { recursive: true });
|
||||
// A registry that does not parse is never written over (that would unlink every other case).
|
||||
const torn = '{\n "kept": "/somewhere"\n}tail"\n}';
|
||||
writeFileSync(LINKED(), torn);
|
||||
const res = await create(app, { name: 'picked', path: join(work(), 'picked') });
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.statusCode).toBe(409);
|
||||
expect(res.json().errorCode).toBe('CONFLICT');
|
||||
expect(res.json().error).toContain('is not a valid JSON object');
|
||||
expect(existsSync(join(work(), 'picked'))).toBe(true);
|
||||
expect(readdirSync(join(work(), 'picked'))).toEqual([]);
|
||||
expect(readFileSync(LINKED(), 'utf8')).toBe(torn);
|
||||
});
|
||||
|
||||
it('a request without a path still creates under the cases directory, as before', async () => {
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
@@ -36,6 +35,21 @@ let claudeCfg = '';
|
||||
const made: string[] = [];
|
||||
|
||||
const linked = (): Record<string, string> => (existsSync(LINKED()) ? JSON.parse(readFileSync(LINKED(), 'utf8')) : {});
|
||||
/**
|
||||
* The registry is replaced by renaming a temp file over it, which a read-only file does not stop, so a
|
||||
* failed registry write is injected at that rename. `before` runs first (another writer landing then).
|
||||
*/
|
||||
const failRegistryWrite = (before?: () => Promise<unknown>, alsoFail?: (from: string) => boolean) => {
|
||||
const realRename = fs.rename;
|
||||
return vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => {
|
||||
if (to === LINKED()) {
|
||||
if (before) await before();
|
||||
throw Object.assign(new Error('permission denied'), { code: 'EACCES' });
|
||||
}
|
||||
if (alsoFail?.(String(from))) throw Object.assign(new Error('resource busy'), { code: 'EBUSY' });
|
||||
return realRename(from, to);
|
||||
});
|
||||
};
|
||||
const settings = (): Record<string, unknown> => JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'));
|
||||
const rename = (app: Awaited<ReturnType<typeof createRouteTestHarness>>['app'], name: string, payload: object) =>
|
||||
app.inject({ method: 'POST', url: `/api/cases/${name}/rename`, payload });
|
||||
@@ -278,8 +292,13 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
|
||||
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 });
|
||||
const spy = failRegistryWrite();
|
||||
let res;
|
||||
try {
|
||||
res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(existsSync(real)).toBe(true);
|
||||
expect(linked()).toEqual({ proj: real });
|
||||
@@ -294,14 +313,9 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
|
||||
mkdirSync(real);
|
||||
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
|
||||
writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' }));
|
||||
chmodSync(LINKED(), 0o444);
|
||||
// Another writer (a settings save) lands after the rename wrote settings.json and before the
|
||||
// registry write fails.
|
||||
const realWriteFile = fs.writeFile;
|
||||
const spy = vi.spyOn(fs, 'writeFile').mockImplementation(async (file, data, options) => {
|
||||
if (file === LINKED()) await mutateSettingsFile((current) => ({ ...current, agentSkillEnabled: true }));
|
||||
return realWriteFile(file, data, options);
|
||||
});
|
||||
const spy = failRegistryWrite(() => mutateSettingsFile((current) => ({ ...current, agentSkillEnabled: true })));
|
||||
try {
|
||||
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
|
||||
expect(res.statusCode).toBe(500);
|
||||
@@ -351,13 +365,8 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
|
||||
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);
|
||||
});
|
||||
const spy = failRegistryWrite(undefined, (from) => from === moved);
|
||||
try {
|
||||
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
|
||||
expect(res.statusCode).toBe(500);
|
||||
@@ -373,9 +382,14 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
|
||||
const real = join(work, 'my-project');
|
||||
mkdirSync(real);
|
||||
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
|
||||
chmodSync(LINKED(), 0o444);
|
||||
(ctx.sessions as Map<string, unknown>).set('s1', { id: 's1', name: 'w1', workingDir: real });
|
||||
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true, stopSessions: true });
|
||||
const spy = failRegistryWrite();
|
||||
let res;
|
||||
try {
|
||||
res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true, stopSessions: true });
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
expect(res.statusCode).toBe(500);
|
||||
expect(res.json().stoppedSessions).toBe(1);
|
||||
expect(ctx.cleanupSession).toHaveBeenCalledWith('s1', true, 'case_rename');
|
||||
|
||||
@@ -54,6 +54,12 @@ vi.mock('node:fs/promises', () => ({
|
||||
throw err;
|
||||
}),
|
||||
writeFile: vi.fn(async () => {}),
|
||||
// What the linked-cases writer needs to replace the registry atomically (temp file + rename).
|
||||
realpath: vi.fn(async (p: string) => p),
|
||||
mkdir: vi.fn(async () => undefined),
|
||||
chmod: vi.fn(async () => {}),
|
||||
rename: vi.fn(async () => {}),
|
||||
rm: vi.fn(async () => {}),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -704,6 +710,7 @@ describe('case-routes', () => {
|
||||
});
|
||||
// linked-cases.json doesn't exist yet
|
||||
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
mockedStat.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
|
||||
Reference in new issue
Block a user