mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
Merge pull request #719: fix(settings): serialize settings.json writes so a quick-start cannot tear the file
Conflict in src/web/routes/system-routes.ts (PUT /api/settings) resolved by keeping both sides: #718's line that never stores an unauthorized tunnelEnabled:true runs first, then #719's serialized mutateSettingsFile() write replaces the inline read and plain writeFile. test/routes/system-routes-tunnel-guard.test.ts: #718's new tests asserted plain writeFile calls on settings.json. With #719 every write goes to a temp file renamed over it, so the assertions read writes through a settingsWrites() helper (writeFile contents keyed by their rename), and the concurrent-stop harness applies a write and the stop at the rename. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
82ef8b1edd
20 files changed
+532
-131
No files matched your search
@@ -78,6 +78,12 @@ import {
|
||||
parseBody,
|
||||
readJsonConfig,
|
||||
} from '../route-helpers.js';
|
||||
import {
|
||||
assertSettingsWritable,
|
||||
mutateSettingsFile,
|
||||
SettingsWriteRefusedError,
|
||||
settingsWriteErrorResponse,
|
||||
} from '../settings-writer.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import type { AuthUser } from '../../types.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
@@ -1849,6 +1855,17 @@ export function registerCaseRoutes(
|
||||
if (open.some((s) => !canAccessOwned(user, s.owner))) {
|
||||
return fail(403, ApiErrorCode.FORBIDDEN, "Another user's session has this case open");
|
||||
}
|
||||
// The admin's rename writes settings.json only after the sessions are ended and the folder moved, and
|
||||
// the writer refuses a file that is not a JSON object: check it by the writer's own rule now, so no
|
||||
// session is ended for a rename that cannot complete. Not between the starting check and the lock below.
|
||||
if (isAdmin(req)) {
|
||||
try {
|
||||
await assertSettingsWritable();
|
||||
} catch (err) {
|
||||
if (err instanceof SettingsWriteRefusedError) return fail(409, ApiErrorCode.CONFLICT, err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
// A session being created in the folder is not in ctx.sessions yet, so it could be neither listed nor
|
||||
// stopped: it would start in the old path after the move. Checked in the same tick that takes the lock
|
||||
// below (nothing is awaited in between), and the Session constructor refuses the folder from then on.
|
||||
@@ -1876,27 +1893,23 @@ export function registerCaseRoutes(
|
||||
}
|
||||
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<string, unknown> | 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;
|
||||
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<Record<string, unknown>>(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
|
||||
)
|
||||
);
|
||||
const { before, after } = await mutateSettingsFile((existing) => {
|
||||
const order = renameInOrder(existing.caseOrder, name, newName);
|
||||
const last = existing.lastUsedCase === name ? newName : undefined;
|
||||
if (!order && !last) return undefined;
|
||||
return { ...existing, ...(order ? { caseOrder: order } : {}), ...(last ? { lastUsedCase: last } : {}) };
|
||||
});
|
||||
if (after) {
|
||||
const keys = ['caseOrder', 'lastUsedCase'].filter((k) => after[k] !== before[k]);
|
||||
settingsUndo = Object.fromEntries(keys.map((k) => [k, before[k]]));
|
||||
}
|
||||
}
|
||||
if (originalLinks) {
|
||||
@@ -1913,9 +1926,8 @@ export function registerCaseRoutes(
|
||||
} 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(() => {});
|
||||
}
|
||||
const undo = settingsUndo;
|
||||
if (undo) await mutateSettingsFile((current) => ({ ...current, ...undo })).catch(() => {});
|
||||
if (moveFolder) {
|
||||
const movedBack = await fs.rename(newPath, oldPath).then(
|
||||
() => true,
|
||||
@@ -1970,22 +1982,11 @@ export function registerCaseRoutes(
|
||||
const { order } = parseBody(CaseOrderSchema, req.body, 'Invalid order data');
|
||||
|
||||
try {
|
||||
const dir = getDataDir();
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
let existing: Record<string, unknown> = {};
|
||||
try {
|
||||
existing = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8'));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const merged = { ...existing, caseOrder: order };
|
||||
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
|
||||
await mutateSettingsFile((current) => ({ ...current, caseOrder: order }));
|
||||
ctx.broadcast(SseEvent.CaseOrderChanged, { order });
|
||||
return { success: true, data: { order } };
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
return settingsWriteErrorResponse(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -128,8 +128,8 @@ export async function runConfigBackup(
|
||||
reason: string,
|
||||
opts: { force?: boolean } = {}
|
||||
): Promise<{ result: ConfigBackupResult; removed: string[] } | null> {
|
||||
// Read settings.json directly: readJsonConfig answers {} for ANY failure, so a syntax error or a read
|
||||
// that lands inside PUT /api/settings's non-atomic write used to prune with the defaults (20 / 30
|
||||
// Read settings.json directly: readJsonConfig answers {} for ANY failure, so a syntax error (or, before
|
||||
// settings writes became atomic, a read that landed inside one) used to prune with the defaults (20 / 30
|
||||
// days) and drop the longer history the user asked to keep.
|
||||
const raw = await readSettingsForBackup();
|
||||
const unreadable = raw === 'unreadable';
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { join, dirname, resolve, relative, isAbsolute } from 'node:path';
|
||||
import { join, resolve, relative, isAbsolute } from 'node:path';
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
|
||||
@@ -20,10 +20,10 @@ import {
|
||||
ownerFor,
|
||||
resolveCasesDir,
|
||||
sessionCapacityMessage,
|
||||
SETTINGS_PATH,
|
||||
findSessionOrFail,
|
||||
parseBody,
|
||||
} from '../route-helpers.js';
|
||||
import { mutateSettingsFile } from '../settings-writer.js';
|
||||
import { resolveClaudeModeForUsername } from '../../user-store.js';
|
||||
import { writeHooksConfig, stripCaseEnvKeys } from '../../hooks-config.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
@@ -470,18 +470,10 @@ export function registerRalphRoutes(
|
||||
// in single-user mode.
|
||||
if (isAdmin(req)) {
|
||||
try {
|
||||
let settings: Record<string, unknown> = {};
|
||||
try {
|
||||
settings = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8'));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
settings.lastUsedCase = caseName;
|
||||
const dir = dirname(SETTINGS_PATH);
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
fs.writeFile(SETTINGS_PATH, JSON.stringify(settings, null, 2)).catch(() => {}); // Ignore - persisting lastUsedCase is non-critical
|
||||
} catch {
|
||||
/* non-critical */
|
||||
await mutateSettingsFile((settings) => ({ ...settings, lastUsedCase: caseName }));
|
||||
} catch (err) {
|
||||
// Non-critical: the loop starts regardless.
|
||||
console.warn('[Server] Failed to save settings (lastUsedCase):', getErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -114,11 +114,11 @@ import {
|
||||
resolveCasesDir,
|
||||
resolveParentSessionId,
|
||||
sessionCapacityMessage,
|
||||
SETTINGS_PATH,
|
||||
validatePathWithinBase,
|
||||
waitCapacityResponse,
|
||||
abortOnClientHangUp,
|
||||
} from '../route-helpers.js';
|
||||
import { mutateSettingsFile } from '../settings-writer.js';
|
||||
import { buildAgentCaseMarker, writeAgentCaseMarker } from '../../agent-case-marker.js';
|
||||
import { agentInbox } from '../agent-inbox.js';
|
||||
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js';
|
||||
@@ -4576,28 +4576,14 @@ export function registerSessionRoutes(
|
||||
// settings.json, readable by every user through GET /api/settings, so a non-admin's
|
||||
// case name is not written there in multi-user mode (writes are admin-only, like
|
||||
// PUT /api/settings). isAdmin is always true in single-user mode.
|
||||
// Awaited and serialized with every other settings.json writer (mutateSettingsFile): the
|
||||
// un-awaited write this replaced could land after the response, over a settings save.
|
||||
if (isAdmin(req)) {
|
||||
try {
|
||||
const settingsFilePath = SETTINGS_PATH;
|
||||
let settings: Record<string, unknown> = {};
|
||||
try {
|
||||
settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8'));
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
||||
}
|
||||
settings.lastUsedCase = caseName;
|
||||
const dir = dirname(settingsFilePath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
// Use async write to avoid blocking event loop
|
||||
fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch((err) => {
|
||||
// Non-critical but log for debugging
|
||||
console.warn('[Server] Failed to save settings (lastUsedCase):', err);
|
||||
});
|
||||
await mutateSettingsFile((settings) => ({ ...settings, lastUsedCase: caseName }));
|
||||
} catch (err) {
|
||||
// Non-critical but log for debugging
|
||||
console.warn('[Server] Failed to prepare settings update:', err);
|
||||
// Non-critical: the session is already running.
|
||||
console.warn('[Server] Failed to save settings (lastUsedCase):', getErrorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
toggleService,
|
||||
SETTINGS_PATH,
|
||||
} from '../route-helpers.js';
|
||||
import { mutateSettingsFile, settingsWriteErrorResponse } from '../settings-writer.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js';
|
||||
|
||||
@@ -1076,11 +1077,10 @@ export function registerSystemRoutes(
|
||||
|
||||
app.get('/api/settings', async (req) => {
|
||||
// A plain read. This route must NEVER write settings.json: readJsonConfig()
|
||||
// answers `{}` for ANY read failure (a parse error, EACCES, EMFILE, a read
|
||||
// that lands inside PUT's non-atomic write), not only for a missing file,
|
||||
// and every page load calls this route, so a "persist the default when the
|
||||
// key is absent" reconcile here replaced a whole settings file with one key
|
||||
// on the first unlucky read. The plan-usage default is resolved by the
|
||||
// answers `{}` for ANY read failure (a parse error, EACCES, EMFILE), not
|
||||
// only for a missing file, and every page load calls this route, so a
|
||||
// "persist the default when the key is absent" reconcile here replaced a
|
||||
// whole settings file with one key on the first unlucky read. The plan-usage default is resolved by the
|
||||
// READERS instead: an absent `showPlanUsageLimits` means ON to
|
||||
// readPlanUsageTelemetryEnabled() (hooks-config.ts), the same way an absent
|
||||
// `workspaceHooksEnabled` means ON, and the client resolves its own display
|
||||
@@ -1133,16 +1133,6 @@ export function registerSystemRoutes(
|
||||
}
|
||||
|
||||
try {
|
||||
const dir = dirname(SETTINGS_PATH);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
let existing: Record<string, unknown> = {};
|
||||
try {
|
||||
existing = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8'));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// acknowledgeUnauthTunnel is an ACTION field (not a stored setting) — strip
|
||||
// it before persisting so settings.json stays clean.
|
||||
const { acknowledgeUnauthTunnel, ...settingsToStore } = settings;
|
||||
@@ -1150,8 +1140,8 @@ export function registerSystemRoutes(
|
||||
// It must never write tunnelEnabled:true: a concurrent stop has written false by now,
|
||||
// and true on disk would show a stopped tunnel as on. The stored value stays as it is.
|
||||
if (settings.tunnelEnabled === true && !tunnelEnableAuthorized) delete settingsToStore.tunnelEnabled;
|
||||
const { before: existing } = await mutateSettingsFile((current) => ({ ...current, ...settingsToStore }));
|
||||
const merged = { ...existing, ...settingsToStore };
|
||||
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2));
|
||||
|
||||
// tmux 3.7+ resizes tracked panes; older versions apply this to new panes.
|
||||
// Already-evicted history cannot be recovered on either version.
|
||||
@@ -1251,7 +1241,7 @@ export function registerSystemRoutes(
|
||||
|
||||
return {};
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
return settingsWriteErrorResponse(err);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1269,25 +1259,10 @@ export function registerSystemRoutes(
|
||||
const modelConfig = parseBody(ModelConfigUpdateSchema, req.body, 'Invalid model config') as Record<string, unknown>;
|
||||
|
||||
try {
|
||||
let existingSettings: Record<string, unknown> = {};
|
||||
try {
|
||||
const content = await fs.readFile(SETTINGS_PATH, 'utf-8');
|
||||
existingSettings = JSON.parse(content);
|
||||
} catch {
|
||||
// File doesn't exist yet, start fresh
|
||||
}
|
||||
|
||||
existingSettings.modelConfig = modelConfig;
|
||||
|
||||
const dir = dirname(SETTINGS_PATH);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
await fs.writeFile(SETTINGS_PATH, JSON.stringify(existingSettings, null, 2));
|
||||
|
||||
await mutateSettingsFile((current) => ({ ...current, modelConfig }));
|
||||
return {};
|
||||
} catch (err) {
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
return settingsWriteErrorResponse(err);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+5
-8
@@ -174,6 +174,7 @@ import { MAX_PASTE_IMAGE_BYTES } from '../config/buffer-limits.js';
|
||||
import { resolveTerminalHistoryConfig } from '../config/terminal-history.js';
|
||||
import { SseEvent } from './sse-events.js';
|
||||
import { MAX_ROUTE_PARAM_LENGTH } from './route-helpers.js';
|
||||
import { mutateSettingsFile } from './settings-writer.js';
|
||||
import { getLatestPlanUsage, setLatestCodexPlanUsage, setLatestCopilotPlanUsage } from './plan-usage-latest.js';
|
||||
import { telemetrySignature } from '../usage-telemetry.js';
|
||||
import { readCodexPlanUsage, resolveCodexBinaryPath } from '../utils/codex-cli-resolver.js';
|
||||
@@ -3351,16 +3352,12 @@ export class WebServer extends EventEmitter {
|
||||
// Tunnel only starts when user clicks the toggle in the UI — never on boot.
|
||||
// Reset persisted tunnelEnabled so the UI toggle reflects actual state.
|
||||
if (await this.isTunnelEnabled()) {
|
||||
const settingsPath = dataPath('settings.json');
|
||||
try {
|
||||
const content = await fs.readFile(settingsPath, 'utf-8');
|
||||
const settings = JSON.parse(content);
|
||||
settings.tunnelEnabled = false;
|
||||
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
|
||||
} catch {
|
||||
/* ignore */
|
||||
await mutateSettingsFile((settings) => ({ ...settings, tunnelEnabled: false }));
|
||||
console.log('Cloudflare tunnel setting reset (tunnel only starts on explicit UI toggle)');
|
||||
} catch (err) {
|
||||
console.warn('Could not reset the Cloudflare tunnel setting:', getErrorMessage(err));
|
||||
}
|
||||
console.log('Cloudflare tunnel setting reset (tunnel only starts on explicit UI toggle)');
|
||||
}
|
||||
|
||||
// Start team watcher for agent team awareness (always on — lightweight polling)
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* @fileoverview The writer of the server's `settings.json` (`SETTINGS_PATH`).
|
||||
*
|
||||
* Every read-modify-write of that file goes through `mutateSettingsFile()`, the same shape as
|
||||
* `mutateRegistryFile()` (config/cli-registry/registry-writer.ts). The one direct writer is
|
||||
* config-backup restore (`restoreConfigBackup()`, as for `clis.json`): it must work on a file that
|
||||
* does not parse, which this writer refuses.
|
||||
*
|
||||
* - **Serialized.** Mutations run one at a time on a single promise chain, each one reading,
|
||||
* changing and writing before the next starts. The writers used to run unserialized with a
|
||||
* plain truncate-then-write `fs.writeFile`, and quick-start did not even await its
|
||||
* `lastUsedCase` write, so a quick-start followed by a settings save could tear the file (one
|
||||
* write's bytes over the tail of a longer one) or drop the other writer's key.
|
||||
* - **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.
|
||||
* - **Refuses a file it must not trust.** Only a missing (or empty) file starts from `{}`. A file
|
||||
* that cannot be read or does not parse is left alone and the write throws
|
||||
* `SettingsWriteRefusedError`: the writers used to read such a file as `{}` and write back only
|
||||
* their own keys, which erased every other setting.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import fs from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../types.js';
|
||||
import { SETTINGS_PATH } from './route-helpers.js';
|
||||
|
||||
export type SettingsFile = Record<string, unknown>;
|
||||
|
||||
/** A write refused because the existing `settings.json` must not be overwritten. The message is user-facing. */
|
||||
export class SettingsWriteRefusedError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'SettingsWriteRefusedError';
|
||||
}
|
||||
}
|
||||
|
||||
async function readSettingsForWrite(path: string): Promise<SettingsFile> {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(path, 'utf-8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {};
|
||||
throw new SettingsWriteRefusedError(
|
||||
`Cannot read ${path} (${(err as Error).message}), so this change was not saved.`
|
||||
);
|
||||
}
|
||||
// An empty file holds no settings to lose.
|
||||
if (raw.trim() === '') return {};
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
parsed = undefined;
|
||||
}
|
||||
// The parser's own message is not repeated: it can quote the file, which may hold an API key.
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
throw new SettingsWriteRefusedError(
|
||||
`${path} is not a valid JSON object, so this change was not saved (saving would have replaced every other setting in it). Fix or delete the file, or restore it from a config backup, then try again.`
|
||||
);
|
||||
}
|
||||
return parsed as SettingsFile;
|
||||
}
|
||||
|
||||
async function writeSettingsFile(path: string, settings: SettingsFile): 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 });
|
||||
const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(tmp, JSON.stringify(settings, null, 2), { mode: 0o600 });
|
||||
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 `settings.json`. `fn` receives the current settings and returns the settings
|
||||
* to write, or `undefined` to write nothing. Resolves with the settings 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 mutateSettingsFile(
|
||||
fn: (current: SettingsFile) => SettingsFile | undefined
|
||||
): Promise<{ before: SettingsFile; after: SettingsFile | undefined }> {
|
||||
const run = mutationChain.then(async () => {
|
||||
const before = await readSettingsForWrite(SETTINGS_PATH).catch((err: unknown) => {
|
||||
console.warn(`[settings] write refused: ${getErrorMessage(err)}`);
|
||||
throw err;
|
||||
});
|
||||
const after = fn({ ...before });
|
||||
if (after) await writeSettingsFile(SETTINGS_PATH, after);
|
||||
return { before, after };
|
||||
});
|
||||
mutationChain = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects with `SettingsWriteRefusedError` when `mutateSettingsFile()` would refuse a write right
|
||||
* now: the writer's own read-for-write rule, run on its own. For a route that does something it
|
||||
* cannot take back (case rename ends sessions) before its settings write. A check only, outside
|
||||
* the chain (writes are atomic, so it never reads a half-written file); the file can still change
|
||||
* before the write.
|
||||
*/
|
||||
export async function assertSettingsWritable(): Promise<void> {
|
||||
await readSettingsForWrite(SETTINGS_PATH);
|
||||
}
|
||||
|
||||
/** A refused write answers 409 CONFLICT with the fix; any other failure stays OPERATION_FAILED. */
|
||||
export function settingsWriteErrorResponse(err: unknown): ApiResponse<never> {
|
||||
if (err instanceof SettingsWriteRefusedError) return createErrorResponse(ApiErrorCode.CONFLICT, err.message);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
Reference in new issue
Block a user