Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
df3d50e
docs(backup): design spec for verified, restorable Postgres backups
atomantic Jun 5, 2026
d1e047b
docs(backup): implementation plan for verified, restorable Postgres b…
atomantic Jun 5, 2026
0a080e6
feat(backup): classify pg_dump outcome as ok/skipped/failed with veri…
atomantic Jun 5, 2026
5cfbfa3
feat(socket): export getIo() accessor for unattended emit paths
atomantic Jun 5, 2026
e35ef49
feat(backup): degraded status + pgBackup state + warning toast + dump…
atomantic Jun 5, 2026
cb49658
fix(backup): clear stale pgBackup on rsync-failure path; document par…
atomantic Jun 5, 2026
4658611
docs(backup): record deferred runBackup integration-test follow-up
atomantic Jun 5, 2026
aaeaf96
feat(backup): add restorePostgres with dry-run default and traversal …
atomantic Jun 5, 2026
4b04792
feat(backup): POST /api/backup/restore-db route + restoreDbRequestSchema
atomantic Jun 5, 2026
a8a4c86
feat(backup): add restoreDatabase client API wrapper
atomantic Jun 5, 2026
6807b68
feat(backup): surface DB backup status, degraded banner, and Restore …
atomantic Jun 5, 2026
f707f56
refactor(backup): use shared Modal for Restore DB confirmation; clear…
atomantic Jun 5, 2026
d44ab1c
docs(changelog): verified restorable Postgres backups
atomantic Jun 5, 2026
f07c916
fix(backup): surface degraded DB-dump toast despite warning severity;…
atomantic Jun 5, 2026
b75d675
address review (claude): psql ON_ERROR_STOP, empty-dump guard on rest…
atomantic Jun 5, 2026
49d83cb
address review (codex): atomic restore (--single-transaction), --clea…
atomantic Jun 5, 2026
7067793
address review (codex): degrade backup when required Postgres (MEMORY…
atomantic Jun 5, 2026
d3bfd6c
address review (codex): classify degraded backup as normal-severity w…
atomantic Jun 5, 2026
3c63fa1
docs(changelog): move verified-backups entry into v2.16.0 release not…
atomantic Jun 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changelog/v2.16.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Released: 2026-06-05

## Added

- **Verified, restorable database backups** — PostgreSQL now gets the same backup safety as your files. Every backup snapshot already dumped the database alongside the file copy, but a failed or skipped dump was silent — a green "backup complete" could hide that your database (where Memory and the creative catalog live) was never actually captured. Now the dump outcome is explicit and verified: a successful dump reports its size and table count, a dump that's not applicable (you're running without Postgres) reads as a neutral "not configured," and a dump that *should* have worked but failed marks the whole backup "degraded" and raises a warning toast — even on unattended scheduled runs — so you find out the day it breaks, not when you need to restore. The dump is hashed into the snapshot manifest so a truncated file is detectable, and the Backup settings tab now shows the last run's database status and lists your snapshots with a one-click **Restore DB** action (it previews what would be restored, then asks you to confirm before replaying the dump into the live database).
- **PR Watcher task — run a custom AI prompt whenever a PR opens** — A new `pr-watcher` task type (Chief of Staff → Schedule, enable per app on the Automation tab) polls each managed app's GitHub repo and, whenever a pull request is opened against the default branch, dispatches an agent running a prompt you control. A "PR Author Filter" gates triggering on authorship — react to any PR, only your own (the gh-authenticated user / your automation), or only PRs opened by others — and the task prompt is fully editable, so you decide what the agent does for each opened PR (the shipped default reviews the diff and leaves a summary comment; you can rewrite it to label, triage, run checks, or make changes). Pick the provider/model like any other task. It tracks a per-app high-water mark so each PR fires once; the first run after you enable it baselines silently so it only reacts to PRs opened from then on, not your existing backlog.
- **Search & browse more LoRAs per base model on /media/loras** — Each "Top for …" base-model section (Flux 1, Flux 2, Z-Image, ERNIE, HiDream, Qwen) gains a keyword search box and a "Load more" button. Type a term (e.g. a character or style name) to query Civitai live within that base model so you can find a specific LoRA that targets the model you're using, and click "Load more" to page past the top few into Civitai's deeper results (cursor-paginated). Clearing the search drops back to the cached top ranking. The curated picks section is unchanged.
- **Custom scheduled tasks per app** — Each managed app's Automation tab gains a "Custom Tasks" section where you write your own prompt and pick a schedule (interval or cron), and a Chief-of-Staff agent runs it against that app's repo on cadence — just like the built-in task types, but fully your own. Per task you choose priority, autonomy level, and whether the agent works in an isolated worktree and opens a PR (or commits/auto-merges) in the target app. Run-now triggers an immediate pass. Custom tasks also appear (with an app badge) in the Chief of Staff → System Tasks list, where the create/edit form now has an optional app-scope picker so any agent job can be pointed at a specific app or left global.
Expand Down
2 changes: 2 additions & 0 deletions client/src/components/BackupWidget.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import { equalByKeys, equalListByKeys } from '../lib/compareHelpers';

function computeHealth(status) {
if (!status || status.status === 'error') return 'critical';
// 'degraded' = files backed up but the DB dump failed — surface as a warning.
if (status.status === 'degraded') return 'warning';
if (status.status === 'never') return 'warning';
if (status.status === 'running') return 'healthy';
if (!status.lastRun) return 'warning';
Expand Down
113 changes: 109 additions & 4 deletions client/src/components/settings/BackupTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import BrailleSpinner from '../BrailleSpinner';
import ToggleSwitch from '../ToggleSwitch';
import FolderPicker from '../FolderPicker';
import useAsyncAction from '../../hooks/useAsyncAction';
import { getSettings, updateSettings, getBackupStatus, triggerBackup } from '../../services/api';
import Modal from '../ui/Modal';
import { getSettings, updateSettings, getBackupStatus, triggerBackup, getBackupSnapshots, restoreDatabase } from '../../services/api';

// Set equality — rsync --exclude flags are order-independent, so reordering
// is NOT a dirty state; only membership changes (added/removed entries) are.
Expand Down Expand Up @@ -50,10 +51,19 @@ export function BackupTab() {
const [savedDisabledDefaultExcludes, setSavedDisabledDefaultExcludes] = useState([]);
const [defaultExcludes, setDefaultExcludes] = useState([]);
const [newExclude, setNewExclude] = useState('');
const [pgBackup, setPgBackup] = useState(null);
const [backupStatus, setBackupStatus] = useState('never');
const [snapshots, setSnapshots] = useState([]);
const [restoreTarget, setRestoreTarget] = useState(null); // snapshotId pending confirm
const [restorePreview, setRestorePreview] = useState(null); // dry-run result

useEffect(() => {
Promise.all([getSettings(), getBackupStatus({ silent: true }).catch(() => null)])
.then(([settings, status]) => {
Promise.all([
getSettings(),
getBackupStatus({ silent: true }).catch(() => null),
getBackupSnapshots({ silent: true }).catch(() => []),
])
.then(([settings, status, snaps]) => {
const backup = settings?.backup || {};
const saved = backup.destPath || '';
const savedExcludes = asArray(backup.excludePaths);
Expand All @@ -67,6 +77,9 @@ export function BackupTab() {
setDisabledDefaultExcludes(savedDisabled);
setSavedDisabledDefaultExcludes(savedDisabled);
setDefaultExcludes(asArray(status?.defaultExcludes));
setPgBackup(status?.pgBackup ?? null);
setBackupStatus(status?.status ?? 'never');
setSnapshots(Array.isArray(snaps) ? snaps : []);
})
.catch(() => toast.error('Failed to load settings'))
.finally(() => setLoading(false));
Expand Down Expand Up @@ -107,7 +120,11 @@ export function BackupTab() {
if (result?.skipped) {
toast('Backup already running');
} else {
toast.success(`Backup complete — ${result?.filesChanged ?? 0} files changed`, { icon: '💾' });
setPgBackup(result?.pgBackup ?? null);
setBackupStatus(result?.status ?? 'ok');
const dbNote = result?.pgBackup?.status === 'failed' ? ' (DB dump FAILED)' : '';
toast.success(`Backup complete — ${result?.filesChanged ?? 0} files changed${dbNote}`, { icon: '💾' });
getBackupSnapshots({ silent: true }).then(s => setSnapshots(Array.isArray(s) ? s : [])).catch(() => {});
}
return result;
}, { errorMessage: 'Backup failed' });
Expand Down Expand Up @@ -147,8 +164,56 @@ export function BackupTab() {
? 'Save your changes before running — the backup uses saved settings.'
: 'Run a backup snapshot now using saved settings';

const renderPgStatus = () => {
if (!pgBackup) return <span className="text-gray-500">No backup run yet</span>;
if (pgBackup.status === 'ok') {
return <span className="text-port-success">✅ {Math.round((pgBackup.sizeBytes || 0) / 1024)} KB · {pgBackup.tableCount} tables</span>;
}
if (pgBackup.status === 'skipped') {
return <span className="text-gray-400">⏭️ Not configured (file mode)</span>;
}
return <span className="text-port-warning">❌ Dump failed: {pgBackup.reason}</span>;
};

const handleRestoreDb = async (snapshotId) => {
setRestorePreview(null);
// Dry-run first to show what would restore, then open the confirm modal.
const preview = await restoreDatabase({ snapshotId, dryRun: true }, { silent: true })
.catch(() => null);
if (!preview || preview.status === 'skipped') {
toast.error(preview?.reason === 'no_dump' ? 'No DB dump in this snapshot' : 'DB restore unavailable');
return;
}
setRestorePreview(preview);
setRestoreTarget(snapshotId);
};

const confirmRestoreDb = async () => {
const snapshotId = restoreTarget;
setRestoreTarget(null);
const result = await restoreDatabase({ snapshotId, dryRun: false }, { silent: true })
.catch(() => ({ status: 'failed', reason: 'request_error' }));
if (result.status === 'ok') {
toast.success(`Database restored from ${snapshotId}`, { icon: '💾' });
} else {
toast.error(`DB restore failed: ${result.reason || 'unknown'}`);
}
setRestorePreview(null);
};

return (
<div className="bg-port-card border border-port-border rounded-xl p-4 sm:p-6 space-y-5">
{backupStatus === 'degraded' && (
<div className="bg-port-warning/10 border border-port-warning/40 rounded-lg px-3 py-2 text-sm text-port-warning">
⚠️ Last backup degraded — files were saved but the database dump failed. Check that <code>pg_dump</code> is installed and PostgreSQL is reachable.
</div>
)}

<div className="space-y-1">
<label className="block text-sm text-gray-400">Database Backup (last run)</label>
<div className="text-sm">{renderPgStatus()}</div>
</div>

<div className="space-y-1">
<label htmlFor={destPathId} className="block text-sm text-gray-400">Destination Path</label>
<div className="flex gap-2 items-stretch">
Expand Down Expand Up @@ -269,6 +334,46 @@ export function BackupTab() {
)}
</div>

{snapshots.length > 0 && (
<div className="space-y-2">
<label className="block text-sm text-gray-400">Snapshots</label>
<ul className="space-y-1.5">
{snapshots.slice(0, 10).map((snap) => (
<li key={snap.id} className="flex items-center justify-between gap-2 text-xs bg-port-bg border border-port-border rounded-lg px-2.5 py-1.5">
<span className="text-gray-300 truncate">{snap.id}</span>
<button
onClick={() => handleRestoreDb(snap.id)}
className="shrink-0 px-2 py-1 bg-port-border hover:bg-port-border/70 text-white rounded transition-colors"
>
Restore DB
</button>
</li>
))}
</ul>
</div>
)}

<Modal
open={!!restoreTarget}
onClose={() => { setRestoreTarget(null); setRestorePreview(null); }}
size="sm"
usePortal
ariaLabel="Restore database"
>
<div className="bg-port-card border border-port-border rounded-xl p-5 space-y-4">
<h3 className="text-white text-sm font-medium">Restore database?</h3>
<p className="text-sm text-gray-400">
This replays <code>portos-db.sql</code> from snapshot <code className="text-gray-300">{restoreTarget}</code>
{restorePreview && <> ({Math.round((restorePreview.sizeBytes || 0) / 1024)} KB · {restorePreview.tableCount} tables)</>}
{' '}into the live PostgreSQL database. Existing rows may be overwritten.
</p>
<div className="flex justify-end gap-2">
<button onClick={() => { setRestoreTarget(null); setRestorePreview(null); }} className="px-3 py-2 text-sm text-gray-400 hover:text-white transition-colors">Cancel</button>
<button onClick={confirmRestoreDb} className="px-3 py-2 text-sm bg-port-warning hover:bg-port-warning/80 text-black font-medium rounded-lg transition-colors">Restore</button>
</div>
</div>
</Modal>

<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-port-border">
<button
onClick={handleSave}
Expand Down
10 changes: 10 additions & 0 deletions client/src/hooks/useErrorNotifications.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ export function useErrorNotifications() {
socket.emit('errors:subscribe');

const handleError = (error) => {
// A degraded DB backup is warning-severity (the file backup succeeded)
// but MUST still surface — its whole point is "find out the day it
// breaks," including on unattended scheduled runs. Handle it before the
// blanket warning-drop below.
if (error.code === 'BACKUP_DB_DUMP_FAILED') {
toast.error(error.message, { duration: 8000, icon: '💾' });
console.warn(`[${error.code}] ${error.message}`, error.context);
return;
}

// `severity: 'warning'` routes (e.g. speculative GET /api/media-jobs/:id
// 404s for jobs past the 24h archive TTL) opt out of toast + console
// surfacing entirely — the network-tab 404 is sufficient signal.
Expand Down
1 change: 1 addition & 0 deletions client/src/services/apiSystem.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export const getBackupStatus = (options) => request('/backup/status', options);
export const triggerBackup = (options) => request('/backup/run', { method: 'POST', ...options });
export const getBackupSnapshots = (options) => request('/backup/snapshots', options);
export const restoreBackup = (data) => request('/backup/restore', { method: 'POST', body: JSON.stringify(data) });
export const restoreDatabase = (data, options) => request('/backup/restore-db', { method: 'POST', body: JSON.stringify(data), ...options });

// Data Manager
export const getDataOverview = () => request('/data');
Expand Down
10 changes: 8 additions & 2 deletions client/src/utils/cityBackupVault.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,23 @@ const HEALTH_COLORS = {
aging: '#f59e0b', // port-warning — getting old
stale: '#ef4444', // port-error — overdue
error: '#ef4444', // port-error — last run failed
degraded: '#f59e0b', // port-warning — files saved but DB dump failed
never: '#64748b', // slate — never backed up / not configured
running: '#3b82f6', // port-accent — a backup is in flight
};

// Map persisted backup state → a health classification. `state.status` is the stored
// status ('never' | 'ok' | 'error'); `state.lastRun` is the ISO timestamp of the last
// status ('never' | 'ok' | 'degraded' | 'error'); `state.lastRun` is the ISO timestamp of the last
// run (or null); `state.running` is set true while a backup is in flight (socket-driven).
// `now` is injected so the staleness derivation is deterministic in tests.
export function vaultHealth(state, now = Date.now()) {
if (state?.running) return 'running';
const status = state?.status || 'never';
if (status === 'error') return 'error';
// 'degraded' = files backed up but the DB dump failed — alert, don't read as
// PROTECTED. Classified before the staleness path so a recent degraded run
// (fresh lastRun) can't fall through to 'ok'.
if (status === 'degraded') return 'degraded';
if (status === 'never' || !state?.lastRun) return 'never';
const last = new Date(state.lastRun).getTime();
if (!Number.isFinite(last)) return 'never';
Expand All @@ -48,7 +53,7 @@ export function vaultColor(health) {

// Should the vault read as needing attention (urgent pulse, brighter glow)?
export function vaultIsAlerting(health) {
return health === 'stale' || health === 'error';
return health === 'stale' || health === 'error' || health === 'degraded';
}

// Short uppercase label rendered under the monument.
Expand All @@ -59,6 +64,7 @@ export function vaultStatusLabel(health) {
case 'aging': return 'AGING';
case 'stale': return 'STALE';
case 'error': return 'FAILED';
case 'degraded': return 'DB FAILED';
default: return 'NO BACKUP';
}
}
Expand Down
9 changes: 8 additions & 1 deletion client/src/utils/cityBackupVault.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ describe('vaultHealth', () => {
it('reports "running" when a backup is in flight, overriding everything', () => {
expect(vaultHealth({ status: 'error', lastRun: daysAgo(9), running: true }, NOW)).toBe('running');
});

it('reports "degraded" when files saved but the DB dump failed, even on a fresh run', () => {
expect(vaultHealth({ status: 'degraded', lastRun: hoursAgo(1) }, NOW)).toBe('degraded');
});
});

describe('vaultColor', () => {
Expand All @@ -57,6 +61,7 @@ describe('vaultColor', () => {
expect(vaultColor('error')).toBe('#ef4444');
expect(vaultColor('running')).toBe('#3b82f6');
expect(vaultColor('never')).toBe('#64748b');
expect(vaultColor('degraded')).toBe('#f59e0b');
});

it('falls back to the never color for an unknown health', () => {
Expand All @@ -65,9 +70,10 @@ describe('vaultColor', () => {
});

describe('vaultIsAlerting', () => {
it('alerts only on stale or error', () => {
it('alerts on stale, error, or degraded', () => {
expect(vaultIsAlerting('stale')).toBe(true);
expect(vaultIsAlerting('error')).toBe(true);
expect(vaultIsAlerting('degraded')).toBe(true);
expect(vaultIsAlerting('ok')).toBe(false);
expect(vaultIsAlerting('aging')).toBe(false);
expect(vaultIsAlerting('running')).toBe(false);
Expand All @@ -82,6 +88,7 @@ describe('vaultStatusLabel', () => {
expect(vaultStatusLabel('aging')).toBe('AGING');
expect(vaultStatusLabel('stale')).toBe('STALE');
expect(vaultStatusLabel('error')).toBe('FAILED');
expect(vaultStatusLabel('degraded')).toBe('DB FAILED');
expect(vaultStatusLabel('never')).toBe('NO BACKUP');
expect(vaultStatusLabel('bogus')).toBe('NO BACKUP');
});
Expand Down
Loading
Loading