fix(config-backup): probe a custom folder before the retention fallback reads it, test that Save omits a displayed-only retention

This commit is contained in:
Devvyn committed 2026-10-20 21:47:20 +08:00
1 parent 9c0fc30927
commit 8a4ff775fb
9 files changed
+173 -25

No files matched your search

+20 -8
View File
@@ -1374,6 +1374,25 @@ Object.assign(CodemanApp.prototype, {
// ── Config backups ──────────────────────────────────────────────────────────
// Server-side files, so these read the SAVED settings: a folder or retention edit applies after Save.
/**
* The retention keys a Save sends: a value only once it was saved before or edited here. After a reset
* the form merely DISPLAYS what the passes use (the retention the backups recorded), and writing that back
* would replace it with whatever a different folder or an older default says.
*/
_configBackupRetentionPayload(prev) {
const count = document.getElementById('appSettingsConfigBackupKeepCount');
const days = document.getElementById('appSettingsConfigBackupKeepDays');
const out = {};
if (prev.configBackupKeepCount !== undefined || count.dataset.touched) {
out.configBackupKeepCount = Math.min(500, Math.max(2, parseInt(count.value, 10) || 20));
}
if (prev.configBackupKeepDays !== undefined || days.dataset.touched) {
const n = parseInt(days.value, 10);
out.configBackupKeepDays = Math.min(3650, Math.max(0, Number.isNaN(n) ? 30 : n));
}
return out;
},
/** Fill the backup list, and show the default folder as the input's placeholder. */
async loadConfigBackups() {
const out = this.$('configBackupList');
@@ -2887,14 +2906,7 @@ Object.assign(CodemanApp.prototype, {
mcpSyncEnabled: document.getElementById('appSettingsMcpSync').checked,
configBackupEnabled: document.getElementById('appSettingsConfigBackup').checked,
configBackupDir: document.getElementById('appSettingsConfigBackupDir').value.trim(),
// Sent only once saved before or edited here: a value merely displayed from the server (after a reset)
// would otherwise be written back and replace the retention the backups recorded.
...(_prev.configBackupKeepCount !== undefined || document.getElementById('appSettingsConfigBackupKeepCount').dataset.touched
? { configBackupKeepCount: Math.min(500, Math.max(2, parseInt(document.getElementById('appSettingsConfigBackupKeepCount').value, 10) || 20)) }
: {}),
...(_prev.configBackupKeepDays !== undefined || document.getElementById('appSettingsConfigBackupKeepDays').dataset.touched
? { configBackupKeepDays: Math.min(3650, Math.max(0, Number.isNaN(parseInt(document.getElementById('appSettingsConfigBackupKeepDays').value, 10)) ? 30 : parseInt(document.getElementById('appSettingsConfigBackupKeepDays').value, 10))) }
: {}),
...this._configBackupRetentionPayload(_prev),
readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked,
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
+20 -11
View File
@@ -70,12 +70,23 @@ export interface ConfigBackupStatus {
backups: ConfigBackupSummary[];
}
export async function readEffectiveBackupSettings(): Promise<{ settings: ConfigBackupSettings; dir: string }> {
/**
* The settings in effect and the folder they name. A custom folder is probed (bounded, #516) BEFORE
* the recorded-retention fallback reads it, so this throws `BackupDirUnavailableError` for a folder
* that does not answer and never lists it; `present` is false for a custom folder that does not exist yet.
*/
export async function readEffectiveBackupSettings(): Promise<{
settings: ConfigBackupSettings;
dir: string;
present: boolean;
}> {
const raw = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {});
const base = readConfigBackupSettings(raw);
const dir = resolveConfigBackupDir(base, getDataDir());
const present = await probeBackupDir(base, dir);
// The same fallback the timer pass uses, so the form never shows 20 / 30 while a pass keeps 100 / 0.
return { settings: withRecordedRetention(base, raw, dir, readConfigBackupOrigin(getDataDir())), dir };
const settings = present ? withRecordedRetention(base, raw, dir, readConfigBackupOrigin(getDataDir())) : base;
return { settings, dir, present };
}
/** A user-chosen backup folder that is not safe to touch (bounded probe, as for case paths, #516). */
@@ -158,16 +169,16 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void {
app.get('/api/config-backups', async (req, reply): Promise<ApiResponse<ConfigBackupStatus>> => {
const denied = deny(req, reply);
if (denied) return denied;
const { settings, dir } = await readEffectiveBackupSettings();
// Read-only: a listing must not create config-backup-id, and without one nothing can be ours.
const origin = readConfigBackupOrigin(getDataDir());
let present: boolean;
let eff: Awaited<ReturnType<typeof readEffectiveBackupSettings>>;
try {
present = await probeBackupDir(settings, dir);
eff = await readEffectiveBackupSettings();
} catch (err) {
reply.code(503);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
const { settings, dir, present } = eff;
// Read-only: a listing must not create config-backup-id, and without one nothing can be ours.
const origin = readConfigBackupOrigin(getDataDir());
return {
success: true,
data: {
@@ -201,8 +212,7 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, '`from` is required (a backup id or "current")');
}
try {
const { settings, dir } = await readEffectiveBackupSettings();
await probeBackupDir(settings, dir);
const { dir } = await readEffectiveBackupSettings();
const data = diffConfigBackups({
dataDir: getDataDir(),
backupDir: dir,
@@ -229,8 +239,7 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void {
if (denied) return denied;
const { id } = req.params as { id: string };
try {
const { settings, dir } = await readEffectiveBackupSettings();
await probeBackupDir(settings, dir);
const { dir } = await readEffectiveBackupSettings();
const out = restoreConfigBackup({ dataDir: getDataDir(), backupDir: dir, id, appVersion: APP_VERSION });
// IntentStore caches the file and rewrites it whole on the next prompt, which would undo the restore.
if (out.restored.includes('intents.json')) intentStore.reload();