fix(config-backup): a restore that fails partway names what it restored and what failed

Merge-time fix for #661. A restore that could not write one file (a full
disk, a directory where the file should be) used to stop there and answer a
bare 500, naming neither the files it had already put back nor the
pre-restore copy, and the Settings form was not re-read, so the next Save
wrote the old settings back over a restored settings.json.

restoreConfigBackup now writes every verified file it can, then throws
ConfigBackupPartialRestoreError with restored, failed (name and error),
untouched and safetySnapshot. The route runs the same reload hooks and audit
for the files written as a full restore does, and answers 500 with the error
envelope plus those fields (and restartRequired) in data, as the tab-layout
409 carries its layout. Settings refills the form when settings.json is among
the restored files, also on that path. Tests: a unit case where a later file
is still restored, the clis.json-as-a-directory route case, and two vm cases
for the Settings handler.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-22 19:14:08 +02:00
1 parent 8a4ff775fb
commit 0952a2ee47
8 files changed
+196 -33

No files matched your search

+10 -3
View File
@@ -1516,18 +1516,25 @@ Object.assign(CodemanApp.prototype, {
const res = await this._apiPost(`/api/config-backups/${encodeURIComponent(id)}/restore`, {});
let body = null;
try { body = res ? await res.json() : null; } catch { /* fall through */ }
if (!res || !res.ok || !body || body.success === false) {
const ok = res && res.ok && body && body.success !== false;
const d = body?.data;
// A restore that failed partway still answers with what it wrote (`restored`, `failed`, `safetySnapshot`):
// those files are the backup's now and need the same refill as a full restore.
if (!ok && !Array.isArray(d?.restored)) {
this.showToast(body?.error || 'Restore failed', 'error');
return;
}
const d = body.data;
const restart = d.restartRequired?.length ? ` Restart Codeman to apply: ${d.restartRequired.join(', ')}.` : '';
// The form still holds the pre-restore values, and Save would PUT them over the restored file.
if (d.restored.includes('settings.json')) {
await this.loadAppSettingsFromServer();
this.openAppSettings();
}
this.showToast(`Restored ${d.restored.length} file${d.restored.length === 1 ? '' : 's'}.${restart}`, 'success');
if (ok) {
this.showToast(`Restored ${d.restored.length} file${d.restored.length === 1 ? '' : 's'}.${restart}`, 'success');
} else {
this.showToast(`${body.error || 'Restore failed partway'}.${restart}`, 'error');
}
this.loadConfigBackups();
},
+44 -16
View File
@@ -11,7 +11,9 @@
* are admin only. Responses carry file NAMES and sizes, never file content, except the diff, which shows
* changed values but never a credential's (`.env`, `users.json`, `push-keys.json`, `webhook.json`, `intents.json` and any key that looks
* like a token/secret/password are reported as "changed" only). Restore replaces files the running server
* may have cached: `restartRequired` says which ones only apply after a restart.
* may have cached: `restartRequired` says which ones only apply after a restart. A restore that could not
* write every file answers 500 with the error envelope plus `data` (`restored`, `failed`, `untouched`,
* `safetySnapshot`, `restartRequired`): the files it wrote stay restored and are reloaded like a full one.
*/
import { readFile } from 'node:fs/promises';
@@ -31,6 +33,7 @@ import {
CONFIG_DIFF_CURRENT,
ConfigBackupCorruptError,
ConfigBackupNotFoundError,
ConfigBackupPartialRestoreError,
createConfigBackup,
defaultConfigBackupDir,
diffConfigBackups,
@@ -234,6 +237,28 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void {
}
});
/**
* What a restore that wrote files must do, whether or not every file made it: reload what the running
* server caches, audit it, and name the files that only apply after a restart.
*/
const afterRestore = (req: FastifyRequest, id: string, restored: string[], failed: string[] = []): string[] => {
// IntentStore caches the file and rewrites it whole on the next prompt, which would undo the restore.
if (restored.includes('intents.json')) intentStore.reload();
// restoreConfigBackup writes clis.json directly (the one writer besides mutateRegistryFile).
if (restored.includes('clis.json')) reloadCliRegistry();
// A restore of users.json replaces accounts, password hashes, roles and bypass grants.
if (isMultiUserMode()) {
void appendAdminAudit({
admin: getAuthUser(req).username,
action: 'config_backup_restore',
target: id,
ip: req.ip,
detail: { restored, ...(failed.length > 0 && { failed }) },
});
}
return restored.filter((n) => RESTART_REQUIRED.has(n));
};
app.post('/api/config-backups/:id/restore', async (req, reply) => {
const denied = deny(req, reply);
if (denied) return denied;
@@ -241,27 +266,13 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void {
try {
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();
// restoreConfigBackup writes clis.json directly (the one writer besides mutateRegistryFile).
if (out.restored.includes('clis.json')) reloadCliRegistry();
// A restore of users.json replaces accounts, password hashes, roles and bypass grants.
if (isMultiUserMode()) {
void appendAdminAudit({
admin: getAuthUser(req).username,
action: 'config_backup_restore',
target: id,
ip: req.ip,
detail: { restored: out.restored },
});
}
return {
success: true,
data: {
restored: out.restored,
untouched: out.untouched,
safetySnapshot: out.safetySnapshot,
restartRequired: out.restored.filter((n) => RESTART_REQUIRED.has(n)),
restartRequired: afterRestore(req, id, out.restored),
},
};
} catch (err) {
@@ -273,6 +284,23 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void {
reply.code(409);
return createErrorResponse(ApiErrorCode.CONFLICT, `${err.message}. Nothing was changed.`);
}
if (err instanceof ConfigBackupPartialRestoreError) {
// Some files are the backup's now: the client must learn which (Settings re-reads a restored
// settings.json, or its next Save writes the old values back) and where the undo copy is.
const failedNames = err.failed.map((f) => f.name);
const restartRequired = afterRestore(req, id, err.restored, failedNames);
reply.code(500);
return {
...createErrorResponse(ApiErrorCode.OPERATION_FAILED, err.message),
data: {
restored: err.restored,
failed: err.failed,
untouched: err.untouched,
safetySnapshot: err.safetySnapshot,
restartRequired,
},
};
}
reply.code(err instanceof BackupDirUnavailableError ? 503 : 500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Restore failed: ${getErrorMessage(err)}`);
}