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

+39 -6
View File
@@ -476,6 +476,26 @@ export function pruneConfigBackups(dir: string, opts: PruneOptions): string[] {
export class ConfigBackupNotFoundError extends Error {}
export class ConfigBackupCorruptError extends Error {}
/**
* A restore that wrote some files and could not write others (a full disk, a directory where a file
* should be). Every file passed verification first, so the ones written are the backup's: the caller
* still has to reload what they feed and tell the user which files are which.
*/
export class ConfigBackupPartialRestoreError extends Error {
constructor(
readonly restored: string[],
readonly failed: Array<{ name: string; error: string }>,
readonly untouched: string[],
readonly safetySnapshot: string | null
) {
const done = restored.length ? `${restored.join(', ')} restored` : 'nothing was restored';
const undo = safetySnapshot ? `; the files from before the restore are in backup ${safetySnapshot}` : '';
super(
`Restore incomplete: could not write ${failed.map((f) => f.name).join(', ')} (${failed[0]?.error}); ${done}${undo}`
);
}
}
/**
* Restore refuses what this module never writes. Snapshots are 0700/0600 real files, and a restore
* writes the file as 0600: a symlink, or a file with a group/world bit, was planted or loosened
@@ -556,7 +576,8 @@ export interface RestoreResult {
/**
* Put a snapshot's files back into the data dir. Every file is checked against the manifest's digest
* BEFORE anything is written, a `pre-restore` snapshot of the live files is taken first, and each
* file is replaced atomically (temp + rename) with mode 0600.
* file is replaced atomically (temp + rename) with mode 0600. A file that cannot be written does not
* stop the others; it throws {@link ConfigBackupPartialRestoreError} at the end, naming both lists.
*/
export function restoreConfigBackup(opts: {
dataDir: string;
@@ -603,6 +624,7 @@ export function restoreConfigBackup(opts: {
mkdirSync(opts.dataDir, { recursive: true });
const restored: string[] = [];
const failed: Array<{ name: string; error: string }> = [];
for (const f of loaded) {
const target = join(opts.dataDir, f.name);
const tmp = `${target}.restore-${process.pid}`;
@@ -611,17 +633,28 @@ export function restoreConfigBackup(opts: {
mode: 0o600,
});
renameSync(tmp, target);
restored.push(f.name);
} catch (err) {
rmSync(tmp, { force: true });
throw err;
try {
rmSync(tmp, { force: true });
} catch {
/* the temp is a leftover at worst; the failure below is what matters */
}
// Keep going: each file is replaced on its own, and the files written before this one are the
// backup's already. Stopping here used to answer a bare 500 that named neither list.
failed.push({ name: f.name, error: err instanceof Error ? err.message : String(err) });
}
restored.push(f.name);
}
// A restore never deletes: tracked files the snapshot did not hold stay as they are.
const untouched = CONFIG_BACKUP_FILES.filter(
(n) => !restored.includes(n) && lstatSync(join(opts.dataDir, n), { throwIfNoEntry: false })?.isFile()
(n) =>
!restored.includes(n) &&
!failed.some((x) => x.name === n) &&
lstatSync(join(opts.dataDir, n), { throwIfNoEntry: false })?.isFile()
);
return { restored, untouched, safetySnapshot: safety.status === 'created' ? safety.id : null };
const safetySnapshot = safety.status === 'created' ? safety.id : null;
if (failed.length) throw new ConfigBackupPartialRestoreError(restored, failed, untouched, safetySnapshot);
return { restored, untouched, safetySnapshot };
}
/** The live files in the data dir, as a side of a diff. */
+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)}`);
}