diff --git a/docs/wiki/Config-Backups.md b/docs/wiki/Config-Backups.md index 5b9f0ddc..053c262f 100644 --- a/docs/wiki/Config-Backups.md +++ b/docs/wiki/Config-Backups.md @@ -37,6 +37,10 @@ cron jobs. - **Whenever the content changes.** The files are read and hashed every five minutes; a new backup is written only if the set differs from the latest backup. An idle server writes nothing, and the history is one entry per real change rather than one per timer tick. + A field a file refreshes by itself is not a change: `custom-model-hosts.json` carries a + `lastDiscoveredAt` per host that the model-discovery poll rewrites every five minutes, and + the comparison looks through it (the snapshot still stores the file exactly as written). + Without that, every tick looked like an edit and 20 copies covered only about 100 minutes. - **On demand**, with **Back up now** (always writes one, even if nothing changed). - **Before a restore**, as a `pre-restore` safety copy of the files about to be replaced. diff --git a/src/config-backup.ts b/src/config-backup.ts index 9749c5d0..09223500 100644 --- a/src/config-backup.ts +++ b/src/config-backup.ts @@ -154,6 +154,42 @@ export function resolveConfigBackupDir(settings: ConfigBackupSettings, dataDir: const sha256 = (data: Buffer | string): string => createHash('sha256').update(data).digest('hex'); +/** + * Keys a tracked file rewrites on its own schedule, with no change to anything the user set, so + * they must not make a snapshot look different. `custom-model-hosts.json` carries a + * `lastDiscoveredAt` per host that the model-discovery poll refreshes every 5 minutes: before this, + * the change check never matched and the snapshot folder filled with copies that differed by one + * timestamp, evicting every older snapshot within about 100 minutes at the default keep count. + * The snapshot itself still stores the file exactly as it was, and restore still verifies against + * the file's own digest; only the "has anything changed?" comparison looks through these keys. + */ +const VOLATILE_KEYS: Readonly> = { + 'custom-model-hosts.json': ['lastDiscoveredAt'], +}; + +function withoutKeys(value: unknown, drop: ReadonlySet): unknown { + if (Array.isArray(value)) return value.map((v) => withoutKeys(v, drop)); + if (value && typeof value === 'object') { + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + if (!drop.has(k)) out[k] = withoutKeys(v, drop); + } + return out; + } + return value; +} + +/** Digest used only to decide whether two states are the same: the file's own, minus its volatile keys. */ +function changeDigest(name: string, data: Buffer, rawSha: string): string { + const volatile = VOLATILE_KEYS[name]; + if (!volatile) return rawSha; + try { + return sha256(JSON.stringify(withoutKeys(JSON.parse(data.toString('utf8')), new Set(volatile)))); + } catch { + return rawSha; // not JSON (or unreadable as such): compare the bytes, as for any other file + } +} + function stamp(now: number): string { const d = new Date(now); const p = (n: number, w = 2) => String(n).padStart(w, '0'); @@ -216,20 +252,21 @@ export interface CreateConfigBackupOptions { /** Snapshot the tracked files that exist, unless the latest snapshot already holds exactly this state. */ export function createConfigBackup(opts: CreateConfigBackupOptions): ConfigBackupResult { const now = opts.now ?? Date.now(); - const present: Array<{ name: string; data: Buffer; sha: string }> = []; + const present: Array<{ name: string; data: Buffer; sha: string; change: string }> = []; for (const name of CONFIG_BACKUP_FILES) { try { const path = join(opts.dataDir, name); if (!statSync(path).isFile()) continue; const data = readFileSync(path); - present.push({ name, data, sha: sha256(data) }); + const sha = sha256(data); + present.push({ name, data, sha, change: changeDigest(name, data, sha) }); } catch { /* absent or unreadable: simply not part of this snapshot */ } } if (present.length === 0) return { status: 'empty' }; - const hash = sha256(present.map((f) => `${f.name}\0${f.sha}`).join('\n')); + const hash = sha256(present.map((f) => `${f.name}\0${f.change}`).join('\n')); const existing = listConfigBackups(opts.backupDir); if (!opts.force && existing[0]) { const latest = readManifest(opts.backupDir, existing[0].id); diff --git a/test/config-backup.test.ts b/test/config-backup.test.ts index 1410188e..d409ae9d 100644 --- a/test/config-backup.test.ts +++ b/test/config-backup.test.ts @@ -124,6 +124,35 @@ describe('createConfigBackup', () => { expect(listConfigBackups(backups)).toHaveLength(2); }); + it('does not count a self-refreshed discovery timestamp as a change', () => { + const hosts = (stamp: string, model = 'm1') => + JSON.stringify({ hosts: [{ id: 'h', models: [{ id: model }], lastDiscoveredAt: stamp }] }); + put('custom-model-hosts.json', hosts('2026-10-12T08:44:22.026Z')); + const first = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }); + + // The 5-minute discovery poll rewrites only the timestamp: still the same state. + put('custom-model-hosts.json', hosts('2026-10-12T08:49:22.025Z')); + const again = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 1000 }); + expect(again).toEqual({ status: 'unchanged', id: (first as { id: string }).id }); + expect(listConfigBackups(backups)).toHaveLength(1); + + // A real edit next to a refreshed timestamp is still a change, stored exactly as written. + const edited = hosts('2026-10-12T08:54:22.025Z', 'm2'); + put('custom-model-hosts.json', edited); + const third = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 2000 }); + expect(third.status).toBe('created'); + expect(readFileSync(join(backups, (third as { id: string }).id, 'custom-model-hosts.json'), 'utf8')).toBe(edited); + }); + + it('compares a tracked file that is not valid JSON by its bytes', () => { + put('custom-model-hosts.json', 'not json lastDiscoveredAt 1'); + createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }); + put('custom-model-hosts.json', 'not json lastDiscoveredAt 2'); + expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 1000 }).status).toBe( + 'created' + ); + }); + it('force makes a snapshot even when unchanged, and two in one second get distinct ids', () => { put('settings.json', '{}'); createConfigBackup({ dataDir: data, backupDir: backups, reason: 'a', now: T0 });