From f4b5b64f123e3dfd8af8a2c9a37adb509656c9b6 Mon Sep 17 00:00:00 2001 From: itsskofficial Date: Sun, 11 Oct 2026 11:19:11 +0530 Subject: [PATCH 01/10] feat(backup): back up, restore and snapshot the data folder (#151) One-click backup of SENTIENT_HOME to a zip (SQLite online backup API for the database, config, workspace, skills, files, devices certificate; never keychain secrets). Restore is checked and staged, then applied at the next engine start with a snapshot of the current data first and a rollback on failure. Snapshots are taken before a schema update (PRAGMA user_version), before the first added column, before a Hermes import and before a restore; the last N are kept. Scheduled backups, REST routes, CLI commands and a Settings > Backups section. --- CHANGELOG.md | 6 + desktop/electron/main/index.ts | 12 +- desktop/electron/main/paths.ts | 3 +- desktop/src/features/settings/SchemaForm.tsx | 4 + .../src/features/settings/SettingsPage.tsx | 2 + desktop/src/features/settings/sections.ts | 9 + .../features/settings/sections/Backups.tsx | 347 ++++++++++++ desktop/src/hooks/backups.ts | 32 ++ desktop/src/hooks/queryKeys.ts | 1 + desktop/src/lib/api.ts | 14 + desktop/src/lib/events.ts | 2 + desktop/src/lib/types.ts | 78 +++ desktop/src/types/bridge.d.ts | 13 +- docs/API.md | 70 +++ sentient/app.py | 11 +- sentient/backup/__init__.py | 6 + sentient/backup/archive.py | 500 ++++++++++++++++++ sentient/backup/service.py | 231 ++++++++ sentient/cli.py | 39 ++ sentient/config/schema.py | 22 + sentient/gateway/app.py | 4 + sentient/gateway/routes/__init__.py | 3 + sentient/gateway/routes/backups.py | 59 +++ sentient/migrate/hermes.py | 3 + sentient/store/db.py | 42 +- tests/test_backup.py | 295 +++++++++++ 26 files changed, 1797 insertions(+), 11 deletions(-) create mode 100644 desktop/src/features/settings/sections/Backups.tsx create mode 100644 desktop/src/hooks/backups.ts create mode 100644 sentient/backup/__init__.py create mode 100644 sentient/backup/archive.py create mode 100644 sentient/backup/service.py create mode 100644 sentient/gateway/routes/backups.py create mode 100644 tests/test_backup.py diff --git a/CHANGELOG.md b/CHANGELOG.md index afc5d882..574d7b73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ and versions follow [Semantic Versioning](https://semver.org/). ## [Unreleased] ### Added +- **Backups and restore:** Settings > Backups backs up everything Sentient keeps (chats, memory, tasks, settings, + skills and files) to one file, in a folder you pick, by hand or every day or week. Restore one from the list or from + a file: you see what is in it and confirm, Sentient keeps a snapshot of what you have now, then restarts with the + backup. Passwords, API keys and sign-ins stay in your computer's keychain and are never in a backup; after a restore + Sentient lists the ones to sign in to again. Sentient also takes a snapshot on its own before it updates its + database, before a Hermes import and before a restore, keeps the last five, and any of them can be restored. - **One local model job at a time, your chats first:** a local Ollama model now works on one thing at a time, so background work no longer pushes it off the graphics card and slows your chat to a crawl. Your chat always goes next; tasks, suggestions, memory upkeep and chat titles wait while you are chatting (and for 30 seconds after), and diff --git a/desktop/electron/main/index.ts b/desktop/electron/main/index.ts index 1594b6c0..b8f3dcf1 100644 --- a/desktop/electron/main/index.ts +++ b/desktop/electron/main/index.ts @@ -25,6 +25,7 @@ import type { DictationStatus, NativeNotification, OpenPathTarget, + PickFilesOptions, ShellPrefs, ShortcutId } from '../../src/types/bridge' @@ -361,15 +362,20 @@ function registerIpc(): void { platform: process.platform, arch: process.arch })) - ipcMain.handle(CH.pickFiles, async (_e, options?: { multiple?: boolean; directory?: boolean }) => { + ipcMain.handle(CH.pickFiles, async (_e, options?: PickFilesOptions) => { const parent = win ?? undefined const props: Array<'openFile' | 'openDirectory' | 'multiSelections' | 'showHiddenFiles'> = options?.directory ? ['openDirectory', 'showHiddenFiles'] : ['openFile'] if (options?.multiple !== false) props.push('multiSelections') + const filters = Array.isArray(options?.filters) + ? options.filters + .filter((f) => typeof f?.name === 'string' && Array.isArray(f.extensions)) + .map((f) => ({ name: f.name, extensions: f.extensions.filter((x) => typeof x === 'string' && /^[a-z0-9]{1,10}$/i.test(x)) })) + : undefined const result = parent - ? await dialog.showOpenDialog(parent, { properties: props }) - : await dialog.showOpenDialog({ properties: props }) + ? await dialog.showOpenDialog(parent, { properties: props, filters }) + : await dialog.showOpenDialog({ properties: props, filters }) return result.canceled ? [] : result.filePaths }) ipcMain.on(CH.readyForScreenshot, () => smokeRunner?.ready()) diff --git a/desktop/electron/main/paths.ts b/desktop/electron/main/paths.ts index 3877c9d9..c5721e54 100644 --- a/desktop/electron/main/paths.ts +++ b/desktop/electron/main/paths.ts @@ -16,7 +16,8 @@ export const homePaths = { logs: () => join(sentientHome(), 'logs'), files: () => join(sentientHome(), 'files'), workspace: () => join(sentientHome(), 'workspace'), - skills: () => join(sentientHome(), 'skills') + skills: () => join(sentientHome(), 'skills'), + backups: () => join(sentientHome(), 'backups') } /** diff --git a/desktop/src/features/settings/SchemaForm.tsx b/desktop/src/features/settings/SchemaForm.tsx index 84c3d659..2bc64078 100644 --- a/desktop/src/features/settings/SchemaForm.tsx +++ b/desktop/src/features/settings/SchemaForm.tsx @@ -42,6 +42,10 @@ export const LABELS: Record = { 'models.background_quiet_s': { label: 'Background work waits after you chat', unit: 's' }, 'models.background_on_battery': { label: 'Background work on battery' }, // tasks + 'backups.schedule': { label: 'Back up automatically' }, + 'backups.keep': { label: 'Automatic backups to keep', unit: 'backups' }, + 'backups.snapshots_keep': { label: 'Snapshots to keep', unit: 'snapshots' }, + 'tasks.tick_seconds': { label: 'Scheduler check interval', unit: 's' }, 'tasks.max_concurrent_runs': { label: 'Runs at the same time' }, 'tasks.run_timeout_minutes': { label: 'Stop a run after', unit: 'min' }, diff --git a/desktop/src/features/settings/SettingsPage.tsx b/desktop/src/features/settings/SettingsPage.tsx index a6578c58..007845d5 100644 --- a/desktop/src/features/settings/SettingsPage.tsx +++ b/desktop/src/features/settings/SettingsPage.tsx @@ -9,6 +9,7 @@ import { cn } from '@/lib/utils' import { schemaMatches } from './SchemaForm' import { SETTINGS_SECTIONS, sectionById } from './sections' import { AdvancedSection } from './sections/Advanced' +import { BackupsSection } from './sections/Backups' import { GeneralSection } from './sections/General' import { ModelsSection } from './sections/Models' import { PersonalitySection } from './sections/Personality' @@ -36,6 +37,7 @@ const SECTION_COMPONENTS: Record> = { terminal: TerminalSection, browser: BrowserSection, subagents: SubagentsSection, + backups: BackupsSection, advanced: AdvancedSection } diff --git a/desktop/src/features/settings/sections.ts b/desktop/src/features/settings/sections.ts index 9eb4d545..0357b962 100644 --- a/desktop/src/features/settings/sections.ts +++ b/desktop/src/features/settings/sections.ts @@ -1,4 +1,5 @@ import { + IconArchive, IconBolt, IconBrain, IconChartBar, @@ -145,6 +146,14 @@ export const SETTINGS_SECTIONS: SettingsSectionMeta[] = [ icon: IconChartBar, keywords: ['tokens', 'cost', 'insights', 'statistics'] }, + { + id: 'backups', + label: 'Backups', + description: 'Back up and restore your data, and snapshots taken before updates.', + icon: IconArchive, + keywords: ['backup', 'back up', 'restore', 'snapshot', 'export', 'copy', 'undo', 'schedule', 'new computer', 'move'], + schemaSections: ['backups'] + }, { id: 'advanced', label: 'Advanced', diff --git a/desktop/src/features/settings/sections/Backups.tsx b/desktop/src/features/settings/sections/Backups.tsx new file mode 100644 index 00000000..e7d01b7a --- /dev/null +++ b/desktop/src/features/settings/sections/Backups.tsx @@ -0,0 +1,347 @@ +/** Settings > Backups: back up now, restore with a clear confirmation, and the snapshot list (docs/API.md §20). */ +import { + IconAlertTriangle, + IconArchive, + IconCamera, + IconCheck, + IconDatabaseExport, + IconFileZip, + IconFolder, + IconFolderOpen, + IconHistory, + IconKey, + IconRestore +} from '@tabler/icons-react' +import { useQueryClient } from '@tanstack/react-query' +import { useState } from 'react' +import { toast } from 'sonner' +import { Alert, Badge, Button, Dialog, EmptyState, FormRow, FormSection, Skeleton } from '@/components/ui' +import { useBackUpNow, useBackups, useCancelRestore, useDismissRestore } from '@/hooks/backups' +import { useConfigEditor } from '@/hooks/config' +import { qk } from '@/hooks/queryKeys' +import { api, errorMessage } from '@/lib/api' +import { getBridge } from '@/lib/bridge' +import type { BackupEntry, BackupInspect, BackupSignIn, RestoreResult } from '@/lib/types' +import { formatBytes, formatDateTime, relativeTime } from '@/lib/utils' +import { useConnection } from '@/stores/connection' +import { SchemaForm } from '../SchemaForm' +import type { SectionProps } from '../SettingsPage' + +const KEYCHAIN_NOTE = "Passwords, API keys and sign-ins stay in this computer's keychain and are never in a backup." + +function countsLine(e: BackupEntry): string { + const c = e.counts + const parts = [ + c.chats !== undefined && `${c.chats} ${c.chats === 1 ? 'chat' : 'chats'}`, + c.memories !== undefined && `${c.memories} ${c.memories === 1 ? 'memory' : 'memories'}`, + c.tasks !== undefined && `${c.tasks} ${c.tasks === 1 ? 'task' : 'tasks'}`, + c.skills !== undefined && `${c.skills} ${c.skills === 1 ? 'skill' : 'skills'}` + ].filter(Boolean) + return parts.join(' · ') +} + +function signInNames(items: BackupSignIn[]): string { + return items.map((s) => s.label).join(', ') +} + +export function BackupsSection({ query }: SectionProps) { + const bridge = getBridge() + const qc = useQueryClient() + const backups = useBackups() + const backUp = useBackUpNow() + const cancel = useCancelRestore() + const dismiss = useDismissRestore() + const editor = useConfigEditor() + const restartEngine = useConnection((s) => s.restart) + const [confirm, setConfirm] = useState(null) + const [opening, setOpening] = useState(null) + const [restoring, setRestoring] = useState(false) + const data = backups.data + + const runBackup = (folder?: string) => + backUp.mutate(folder, { + onSuccess: (made) => toast.success('Backed up', { description: made.path }), + onError: (err) => toast.error("The backup didn't work", { description: errorMessage(err) }) + }) + + const pickFolder = async (): Promise => { + const [dir] = await bridge.pickFiles({ directory: true, multiple: false }) + return dir + } + + const openRestore = async (path: string) => { + setOpening(path) + try { + setConfirm(await api.backups.inspect(path)) + } catch (err) { + toast.error("That backup can't be restored", { description: errorMessage(err) }) + } finally { + setOpening(null) + } + } + + const pickBackupFile = async () => { + const [file] = await bridge.pickFiles({ multiple: false, filters: [{ name: 'Sentient backups', extensions: ['zip'] }] }) + if (file) await openRestore(file) + } + + const restart = async () => { + await restartEngine() + window.location.reload() // every screen reads the restored data from scratch + } + + const doRestore = async () => { + if (!confirm) return + setRestoring(true) + try { + await api.backups.restore(confirm.path) + setConfirm(null) + void qc.invalidateQueries({ queryKey: qk.backups }) + if (bridge.isDesktop) { + toast.message('Restoring', { description: 'Sentient is restarting with your backup.' }) + await restart() + } else { + toast.success('Ready to restore', { description: 'Restart Sentient to finish.' }) + } + } catch (err) { + toast.error("That backup can't be restored", { description: errorMessage(err) }) + } finally { + setRestoring(false) + } + } + + if (backups.isLoading) { + return ( +
+ + +
+ ) + } + if (backups.isError || !data) { + return ( + } title="Backups aren't available" action={}> + {errorMessage(backups.error) || 'This version of the engine has no backups.'} + + ) + } + + const customFolder = data.folder !== data.default_folder + + return ( +
+ {data.pending_restore && ( + } + title="A restore is waiting" + action={ +
+ + {bridge.isDesktop && ( + + )} +
+ } + > + The backup from {formatDateTime(data.pending_restore.created_at)} replaces your data when Sentient restarts. +
+ )} + + {data.last_restore && dismiss.mutate()} />} + + + +
+ + +
+
+ {data.folder} + } + > +
+ {customFolder && ( + + )} + {!customFolder && ( + + )} + +
+
+
+ + } disabled={!bridge.isDesktop} loading={opening !== null && !data.backups.some((b) => b.path === opening)} onClick={() => void pickBackupFile()}> + Restore from a file… + + } + > + {data.backups.length === 0 ? ( + } title="No backups here yet" description="Back up now, or restore a backup file from somewhere else." /> + ) : ( + data.backups.map((b) => void openRestore(b.path)} />) + )} + + + + {data.snapshots.length === 0 ? ( + } title="No snapshots yet" description="One appears the next time Sentient updates its database or you import something." /> + ) : ( + data.snapshots.map((s) => void openRestore(s.path)} />) + )} + + + + + setConfirm(null)} onConfirm={() => void doRestore()} /> +
+ ) +} + +function BackupRow({ entry, busy, onRestore }: { entry: BackupEntry; busy: boolean; onRestore: () => void }) { + const title = entry.kind === 'snapshot' ? entry.reason ? entry.reason.charAt(0).toUpperCase() + entry.reason.slice(1) : 'Snapshot' : formatDateTime(entry.created_at) + const sub = [entry.kind === 'snapshot' ? formatDateTime(entry.created_at) : relativeTime(entry.created_at), countsLine(entry), formatBytes(entry.size)].filter(Boolean).join(' · ') + return ( + + {title} + {entry.kind === 'scheduled' && Automatic} + + } + description={sub} + > + + + ) +} + +function RestoreDialog({ + info, + busy, + desktop, + onCancel, + onConfirm +}: { + info: BackupInspect | null + busy: boolean + desktop: boolean + onCancel: () => void + onConfirm: () => void +}) { + const snapshot = info?.kind === 'snapshot' + return ( + !o && !busy && onCancel()} + title={snapshot ? 'Restore this snapshot?' : 'Restore this backup?'} + description={info ? `Made ${formatDateTime(info.created_at)}${info.version ? ` by Sentient ${info.version}` : ''}.` : undefined} + size="md" + footer={ + <> + + + + } + > + {info && ( +
+ {countsLine(info) &&

{countsLine(info)}

} +

+ {snapshot + ? 'Your chats, memory, tasks, settings and skills go back to how they were then. Your files folder stays as it is.' + : 'Your chats, memory, tasks, settings, skills and files are replaced with the ones in this backup. Anything added since then goes away.'}{' '} + Sentient saves a snapshot of what you have now first, so you can undo this. +

+ {info.newer && ( + }> + A newer version of Sentient made this backup. Some things in it may not work in this version. + + )} + } title="Sign-ins are not in backups"> + {info.sign_ins.length + ? `${KEYCHAIN_NOTE} On this computer they keep working. On another computer, sign in again to: ${signInNames(info.sign_ins)}.` + : `${KEYCHAIN_NOTE} If you restore on another computer, add your API keys and connect your apps again.`} + +

{desktop ? 'Sentient restarts to finish. A reply in progress stops.' : 'Restart Sentient to finish.'}

+
+ )} +
+ ) +} + +function LastRestore({ result, onDismiss }: { result: RestoreResult; onDismiss: () => void }) { + if (result.error) { + return ( + } title="The restore didn't happen" action={}> + {result.error} + + ) + } + const redo = result.sign_ins.filter((s) => s.signed_in !== true) + return ( + } + title={`Restored the ${result.kind === 'snapshot' ? 'snapshot' : 'backup'} from ${formatDateTime(result.backup_created_at)}`} + action={ + + } + > + {redo.length ? `Sign in again to: ${signInNames(redo)}. ${KEYCHAIN_NOTE}` : 'Everything that was signed in still works.'} + {result.snapshot && Your data from before is kept as a snapshot below, if you want it back.} + + ) +} diff --git a/desktop/src/hooks/backups.ts b/desktop/src/hooks/backups.ts new file mode 100644 index 00000000..335e9154 --- /dev/null +++ b/desktop/src/hooks/backups.ts @@ -0,0 +1,32 @@ +/** React Query hooks for §20 backups, restore and snapshots. Live updates: `backup.updated` (lib/events.ts). */ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { api } from '@/lib/api' +import { qk } from './queryKeys' + +export function useBackups() { + return useQuery({ queryKey: qk.backups, queryFn: api.backups.get }) +} + +export function useBackUpNow() { + const qc = useQueryClient() + return useMutation({ + mutationFn: (folder?: string) => api.backups.create(folder), + onSuccess: () => void qc.invalidateQueries({ queryKey: qk.backups }) + }) +} + +export function useCancelRestore() { + const qc = useQueryClient() + return useMutation({ + mutationFn: api.backups.cancelRestore, + onSuccess: () => void qc.invalidateQueries({ queryKey: qk.backups }) + }) +} + +export function useDismissRestore() { + const qc = useQueryClient() + return useMutation({ + mutationFn: api.backups.dismissLastRestore, + onSuccess: () => void qc.invalidateQueries({ queryKey: qk.backups }) + }) +} diff --git a/desktop/src/hooks/queryKeys.ts b/desktop/src/hooks/queryKeys.ts index 4f1afbd2..f8ff134f 100644 --- a/desktop/src/hooks/queryKeys.ts +++ b/desktop/src/hooks/queryKeys.ts @@ -8,6 +8,7 @@ export const qk = { bootstrap: ['bootstrap'] as const, stop: ['stop'] as const, config: ['config'] as const, + backups: ['backups'] as const, configSchema: ['config-schema'] as const, sessions: ['sessions'] as const, diff --git a/desktop/src/lib/api.ts b/desktop/src/lib/api.ts index 0c2435f6..4c22d541 100644 --- a/desktop/src/lib/api.ts +++ b/desktop/src/lib/api.ts @@ -12,6 +12,10 @@ * engine does not have them yet; whenever the engine answers, its data is used. */ import type { + BackupEntry, + BackupInspect, + BackupOverview, + RestoreStaged, BrowserProfileCreate, BrowserProfilePatch, BrowserProfiles, @@ -765,6 +769,16 @@ export const api = { } }, + // §20 backups, restore and snapshots ------------------------------------------------------ + backups: { + get: () => http.get('/api/backups'), + create: (folder?: string) => http.post('/api/backups', { folder: folder || null }), + inspect: (path: string) => http.post('/api/backups/inspect', { path }), + restore: (path: string) => http.post('/api/backups/restore', { path }), + cancelRestore: () => http.delete<{ cancelled: boolean }>('/api/backups/restore'), + dismissLastRestore: () => http.delete<{ ok: boolean }>('/api/backups/last-restore') + }, + // §16 webhooks -------------------------------------------------------------------------- hooks: { list: () => withDemoFallback(() => http.get('/api/hooks'), () => demo.hooks()), diff --git a/desktop/src/lib/events.ts b/desktop/src/lib/events.ts index a7d3b06c..6cfb4e60 100644 --- a/desktop/src/lib/events.ts +++ b/desktop/src/lib/events.ts @@ -28,6 +28,7 @@ * source.items -> webhook calls refresh ['hooks'] (last called / call count) * stop.updated -> ['stop'] (Stop everything banner and title bar button; cancels dictation) * model.busy -> ['models', 'busy'] (the title bar's "model busy" indicator) + * backup.updated -> refetch ['backups'] (Settings > Backups) */ import type { QueryClient } from '@tanstack/react-query' import { toast } from 'sonner' @@ -296,6 +297,7 @@ export function installDomainEvents(qc: QueryClient): () => void { // #149 one local model job at a time offs.push(live.onDomain('model.busy', (e) => qc.setQueryData(qk.modelBusy, e.data))) + offs.push(live.onDomain('backup.updated', () => void qc.invalidateQueries({ queryKey: qk.backups }))) // After a reconnect we may have missed events: refresh lists (not transcripts). let prev: SocketState = live.state diff --git a/desktop/src/lib/types.ts b/desktop/src/lib/types.ts index 676ae411..fb1490e2 100644 --- a/desktop/src/lib/types.ts +++ b/desktop/src/lib/types.ts @@ -890,6 +890,8 @@ export interface DomainEventMap { 'stop.updated': StopState // #149 'model.busy': ModelBusy + // §20 + 'backup.updated': BackupUpdated } export type DomainEventType = keyof DomainEventMap @@ -2201,3 +2203,79 @@ export interface HermesResult { jobs?: { created: Array<{ task_id: string; name: string }>; skipped: HermesSkipped[] } mcp?: { added: string[]; skipped: HermesSkipped[] } } + +// §20 backups, restore and snapshots ------------------------------------------------------ +export type BackupKind = 'manual' | 'scheduled' | 'snapshot' +export type BackupSchedule = 'off' | 'daily' | 'weekly' + +/** Something signed in through the keychain. Keychain secrets are never in a backup. */ +export interface BackupSignIn { + kind: 'model' | 'integration' | 'channel' | 'browser' + id: string + label: string + /** After a restore: whether it works on this computer now (`null`: Sentient can't tell). */ + signed_in?: boolean | null +} + +export interface BackupEntry { + name: string + path: string + kind: BackupKind + /** Why a snapshot was taken, e.g. "before importing from Hermes". */ + reason: string | null + created_at: ISODate | null + version: string | null + schema_version: number | null + size: number + /** Snapshots leave out the files folder. */ + includes_files: boolean + counts: { chats?: number; memories?: number; tasks?: number; skills?: number } + sign_ins: BackupSignIn[] +} + +export interface BackupInspect extends BackupEntry { + /** Made by a newer Sentient than this one. */ + newer: boolean + contents: string[] +} + +export interface RestoreStaged { + staged: true + restart_required: true + source: string + created_at: ISODate | null + contents: string[] + sign_ins: BackupSignIn[] +} + +export interface RestoreResult { + restored_at: ISODate + source: string | null + kind: BackupKind | null + backup_created_at: ISODate | null + contents: string[] + sign_ins: BackupSignIn[] + /** The snapshot of the data the restore replaced (restore it to undo). */ + snapshot: string | null + /** A plain sentence when the restore didn't happen; the data was left as it was. */ + error: string | null +} + +export interface BackupOverview { + folder: string + default_folder: string + backups: BackupEntry[] + snapshots: BackupEntry[] + schedule: BackupSchedule + keep: number + snapshots_keep: number + last_scheduled_at: ISODate | null + pending_restore: { source: string | null; created_at: ISODate | null; staged_at: ISODate | null; kind: BackupKind | null } | null + last_restore: RestoreResult | null +} + +export interface BackupUpdated { + what: 'backup' | 'snapshot' | 'restore_staged' | 'restore_cancelled' | 'restore_dismissed' + name?: string + source?: string +} diff --git a/desktop/src/types/bridge.d.ts b/desktop/src/types/bridge.d.ts index b3b43f18..df30044c 100644 --- a/desktop/src/types/bridge.d.ts +++ b/desktop/src/types/bridge.d.ts @@ -91,7 +91,14 @@ export interface ShortcutResult { shortcuts: ShortcutInfo[] } -export type OpenPathTarget = 'home' | 'logs' | 'files' | 'workspace' | 'skills' +export interface PickFilesOptions { + multiple?: boolean + directory?: boolean + /** e.g. `[{ name: 'Sentient backups', extensions: ['zip'] }]` */ + filters?: Array<{ name: string; extensions: string[] }> +} + +export type OpenPathTarget = 'home' | 'logs' | 'files' | 'workspace' | 'skills' | 'backups' export interface VersionInfo { app: string @@ -216,8 +223,8 @@ export interface SentientBridge { /** Smoke-test hook: the current route has rendered and its data has loaded. */ readyForScreenshot(): void /** Native open dialog. Returns absolute paths (drag and drop is the primary path). `directory` picks a folder - * (hidden folders such as ~/.hermes are shown). */ - pickFiles(options?: { multiple?: boolean; directory?: boolean }): Promise + * (hidden folders such as ~/.hermes are shown); `filters` limits files by extension (a backup's `.zip`). */ + pickFiles(options?: PickFilesOptions): Promise /** Whether the window currently has OS focus. */ isFocused(): Promise /** Open a file from Sentient's files folder (`outputs/chart.png`) with the default app. */ diff --git a/docs/API.md b/docs/API.md index 3605fa06..89ffa0c0 100644 --- a/docs/API.md +++ b/docs/API.md @@ -84,6 +84,7 @@ Envelope: `{"type": "task.updated", "data": {...}, "ts": "..."}` | `voice.state` | `{state, session_id}` (mirrors voice socket for other windows) | | `stop.updated` | **StopState** (section 17): Stop everything was turned on or off | | `model.busy` | **ModelBusy** (section 3, `GET /api/models/busy`): what the local model is doing changed (#149). Quick changes are published once (after 0.2 s), and only when something other than `since` changed | +| `backup.updated` | `{what: "backup"\|"snapshot"\|"restore_staged"\|"restore_cancelled"\|"restore_dismissed", name?, source?}` (section 20): refetch `GET /api/backups` | --- @@ -2054,3 +2055,72 @@ suggestion may be `null`. ``` Applying publishes the usual events: `skill.updated` (`state: "pending_review"`), `memory.updated`, `user_model.updated`, `task.updated` and `config.updated`. + +## 20. Backups, restore and snapshots (owner: core) + +One-click backup and restore of the data folder (`SENTIENT_HOME`, default `~/.sentient`), and a snapshot before +anything that changes the database's shape (issue #151). Code: `sentient/backup/`. + +**What a backup holds.** One zip file: `manifest.json`, `sentient.db` (a consistent copy made with SQLite's online +backup API while the engine runs, never a raw copy of the live WAL file), `config.yaml`, `workspace/`, `skills/`, +`files/` and `tls/` (the devices certificate, so paired devices keep trusting it). Never inside: keychain secrets (API +keys, tokens, sign-ins), `gateway.token`, `logs/`, the browser profile, WhatsApp's link, downloaded voice models, +scratch folders and the backups folder itself. The manifest lists the sign-ins that were in the keychain (`sign_ins`) +so the window can say which to redo. Names: `sentient-backup-YYYYMMDD-HHMMSS.zip` (`-auto` for scheduled ones) and +`sentient-snapshot-YYYYMMDD-HHMMSS.zip`. + +**Snapshots** are backups without `files/`, kept in `/backups/snapshots` (the newest `backups.snapshots_keep`). +Sentient takes one: +- when it opens a database whose `PRAGMA user_version` differs from `sentient.store.db.SCHEMA_VERSION` (an update or a + downgrade; a new database has no snapshot). Bump `SCHEMA_VERSION` with any schema change that needs existing + databases updated; +- before the first `store.ensure_column` that actually adds a column to an existing database in a run (at most one per + start), as a safety net for a change that forgot the bump; +- before `POST /api/import/hermes/apply` (section 19); +- before a restore replaces the data (so a restore can be undone). +A snapshot that fails is logged and never keeps Sentient from starting. + +**Restore** is two steps, so nothing in use is swapped under the running engine: `POST /api/backups/restore` checks the +archive (manifest, names, SQLite `quick_check`) and unpacks it into `/backups/restore-pending`; the next engine +start applies it before config or database are opened (the desktop restarts the engine at once). Applying takes a +snapshot of the current data, then replaces exactly the items the manifest lists (`contents`): a snapshot leaves +`files/` alone, and an item listed but empty in the backup ends up empty. If anything fails the current data is put +back. The outcome is kept in `/backups/last-restore.json` until dismissed. + +- `GET /api/backups` → **BackupOverview** +- `POST /api/backups` `{folder?}` → **BackupEntry** (a manual backup into `folder`, else `backups.folder`, else + `/backups`; 400 with a plain `detail` for a relative path or a folder that can't be written) +- `POST /api/backups/inspect` `{path}` → **BackupEntry** + `{newer, contents}` for the confirmation (`newer`: made by + a newer database version than this engine's). 400 with a plain `detail` when it isn't a readable Sentient backup +- `POST /api/backups/restore` `{path}` → `{staged: true, restart_required: true, source, created_at, contents, + sign_ins}`; 400 with a plain `detail` (not a backup, damaged, a name outside the listed items, no database) +- `DELETE /api/backups/restore` → `{cancelled}` (drops a staged restore) +- `DELETE /api/backups/last-restore` → `{ok}` (dismisses the result card) + +**BackupEntry** +```json +{"name": "sentient-backup-20261011-153012.zip", "path": "C:/Users/me/.sentient/backups/sentient-backup-....zip", + "kind": "manual|scheduled|snapshot", "reason": "before importing from Hermes", "created_at": "2026-10-11T10:00:12+00:00", + "version": "3.0.0a0", "schema_version": 1, "size": 1048576, "includes_files": true, + "counts": {"chats": 12, "memories": 40, "tasks": 5, "skills": 3}, + "sign_ins": [{"kind": "model|integration|channel|browser", "id": "openai", "label": "OpenAI"}]} +``` + +**BackupOverview** +```json +{"folder": "...", "default_folder": "/backups", "backups": [BackupEntry], "snapshots": [BackupEntry], + "schedule": "off|daily|weekly", "keep": 5, "snapshots_keep": 5, "last_scheduled_at": "...|null", + "pending_restore": {"source", "created_at", "staged_at", "kind"} | null, + "last_restore": {"restored_at", "source", "kind", "backup_created_at", "contents", "snapshot": "|null", + "error": "plain sentence|null", + "sign_ins": [{"kind", "id", "label", "signed_in": true|false|null}]} | null} +``` +`backups` lists the Sentient backups in `folder`, newest first (other files are ignored); `last_restore.sign_ins[]. +signed_in` is checked live (`null`: Sentient can't tell, e.g. browser sign-ins). + +**Scheduled backups** (config `backups.schedule`, `backups.keep`): checked hourly while the engine runs; only the newest +`keep` scheduled backups are kept, backups made by hand are never deleted. A failure is a `backup` notification. + +**CLI**: `sentient backup [--to FOLDER]`, `sentient restore FILE [--now]` (stages; `--now` applies at once and must only +be used while Sentient is closed). + diff --git a/sentient/app.py b/sentient/app.py index 561d07b9..4fb7cb8b 100644 --- a/sentient/app.py +++ b/sentient/app.py @@ -7,7 +7,7 @@ Start order (stop runs in reverse): store -> memory -> notifications -> integrations (registers plugins) -> builtin tools -> skills -> agent -> subagents -> sandbox -> terminal -> browser -> nodes - -> tasks -> proactivity -> evolution -> user_model -> dreaming -> voice -> channels + -> tasks -> proactivity -> evolution -> user_model -> dreaming -> voice -> channels -> backups Stop everything (``stop_all`` / ``resume``, docs/API.md section 17) is deterministic: it never asks the model. It cancels running chat replies, task runs, helpers, scripts, commands and browser @@ -28,6 +28,7 @@ from sentient.agent.chat_rules import ChatRules from sentient.agent.loop import Agent from sentient.agent.subagents import SubagentManager +from sentient.backup import BackupService from sentient.browser import BrowserService from sentient.channels import ChannelService from sentient.config import SentientConfig, load_config, save_config @@ -74,7 +75,7 @@ def __init__( self.config = config or load_config() self.enable_background = enable_background # tests turn schedulers/pollers off self.bus = EventBus() - self.store = Store(db_path) + self.store = Store(db_path, before_migration=self._snapshot_before_migration) self.llm: LLMProvider = llm or LiteLLMProvider(self.config) # one local model job at a time, chats first (#149): the provider's scheduler, or an idle one for status jobs = getattr(self.llm, "jobs", None) @@ -113,6 +114,7 @@ def __init__( self.channels = ChannelService(self) self.user_model = UserModelService(self) self.dreaming = DreamingService(self) + self.backups = BackupService(self) self._started = False self.stop_state: dict[str, Any] = {"stopped": False, "stopped_at": None, "source": None} # bumped by every stop_all: work queued before it (e.g. a chat message waiting behind a reply) is dropped @@ -127,6 +129,7 @@ def services(self) -> list[Service]: return [ self.notifications, self.integrations, self.subagents, self.sandbox, self.terminal, self.browser, self.nodes, self.tasks, self.proactivity, self.evolution, self.user_model, self.dreaming, self.voice, self.channels, + self.backups, ] async def notify(self, kind: str, message: str, *, title: str | None = None, payload: dict | None = None) -> dict: @@ -209,6 +212,10 @@ async def resume(self, source: str = "desktop") -> dict[str, Any]: log.exception("resuming interrupted tasks failed") return dict(self.stop_state) + async def _snapshot_before_migration(self, reason: str) -> None: + """Store hook: a snapshot of the data folder before the database changes shape (issue #151).""" + await self.backups.snapshot(reason, db_path=self.store.path) + def tool_extra(self) -> dict[str, Any]: return {"app": self, "skills": self.skills, "workspace": self.workspace, "registry": self.registry} diff --git a/sentient/backup/__init__.py b/sentient/backup/__init__.py new file mode 100644 index 00000000..e76b3c5e --- /dev/null +++ b/sentient/backup/__init__.py @@ -0,0 +1,6 @@ +"""Backups, restore and snapshots of Sentient's data folder (issue #151, docs/API.md section 20).""" + +from sentient.backup.archive import BackupError +from sentient.backup.service import BackupService, apply_pending_restore + +__all__ = ["BackupError", "BackupService", "apply_pending_restore"] diff --git a/sentient/backup/archive.py b/sentient/backup/archive.py new file mode 100644 index 00000000..b90d9183 --- /dev/null +++ b/sentient/backup/archive.py @@ -0,0 +1,500 @@ +"""Backup archives of the Sentient home folder (issue #151, docs/API.md section 20). + +A backup is one zip file: + +- ``manifest.json``: what is inside, when and by which version, counts for the confirmation, and the sign-ins + that live in the keychain (so the window can say which ones to redo). +- ``sentient.db``: a consistent copy made with SQLite's online backup API, never a raw copy of a live WAL file. +- ``config.yaml``, ``workspace/``, ``skills/``, ``files/`` and ``tls/`` (the devices certificate). + +Never inside: keychain secrets (API keys, tokens, sign-ins), ``gateway.token``, logs, the browser profile, +WhatsApp's link, downloaded voice models, scratch folders and the backups folder itself. + +A snapshot is the same format without ``files/`` (nothing that takes one changes files). Sentient takes one before +it updates its database and before an import; the last N are kept. + +A restore is staged into ``/backups/restore-pending`` and applied by :func:`apply_pending_restore` the next +time the engine starts, before anything opens the database. What it replaces is saved as a snapshot first, so a +restore can be undone. Only the items the backup's manifest lists are replaced; a snapshot leaves ``files/`` alone. + +Everything here is synchronous file work; the service runs it in a thread. +""" + +from __future__ import annotations + +import contextlib +import json +import logging +import os +import shutil +import sqlite3 +import tempfile +import time +import zipfile +from datetime import UTC, datetime +from pathlib import Path, PurePosixPath +from typing import Any + +from sentient import __version__, paths + +log = logging.getLogger(__name__) + +FORMAT = 1 +MANIFEST = "manifest.json" +DB_NAME = "sentient.db" +CONFIG_NAME = "config.yaml" +DIRS = ("workspace", "skills", "files", "tls") +ITEMS = frozenset({DB_NAME, CONFIG_NAME, *DIRS}) +DB_SIDECARS = ("-wal", "-shm", "-journal") +BACKUP_PREFIX = "sentient-backup-" +SNAPSHOT_PREFIX = "sentient-snapshot-" +PENDING = "restore-pending" +LAST_RESTORE = "last-restore.json" +KINDS = ("manual", "scheduled", "snapshot") + + +class BackupError(Exception): + """A plain sentence the window can show as is.""" + + +# ----------------------------------------------------------------------------- where things go +def backups_root(home: Path | None = None) -> Path: + return (home or paths.home()) / "backups" + + +def snapshots_dir(home: Path | None = None) -> Path: + return backups_root(home) / "snapshots" + + +def pending_dir(home: Path | None = None) -> Path: + return backups_root(home) / PENDING + + +def _stamp() -> str: + return datetime.now(UTC).astimezone().strftime("%Y%m%d-%H%M%S") # local time, for people + + +def _unique(folder: Path, prefix: str, suffix: str = "") -> Path: + base = f"{prefix}{_stamp()}{suffix}" + path = folder / f"{base}.zip" + n = 2 + while path.exists(): + path = folder / f"{base}-{n}.zip" + n += 1 + return path + + +def _inside(path: Path, root: Path) -> bool: + try: + path.resolve().relative_to(root.resolve()) + return True + except (ValueError, OSError): + return False + + +# ----------------------------------------------------------------------------- database +def copy_database(src: Path, dst: Path) -> None: + """Copy a database that may be open and in WAL mode, with SQLite's online backup API. + + The copy is switched to a plain rollback journal so it is one self-contained file (the engine turns WAL back on + when it opens it).""" + if not src.exists(): + raise BackupError("There is no database to back up yet.") + dst.parent.mkdir(parents=True, exist_ok=True) + for suffix in ("", *DB_SIDECARS): + Path(f"{dst}{suffix}").unlink(missing_ok=True) + source = sqlite3.connect(src, timeout=30) + try: + target = sqlite3.connect(dst) + try: + source.backup(target) + target.execute("PRAGMA journal_mode = DELETE") + target.commit() + finally: + target.close() + finally: + source.close() + + +def check_database(db: Path) -> None: + """Raise BackupError unless SQLite's quick check passes.""" + try: + con = sqlite3.connect(db) + try: + row = con.execute("PRAGMA quick_check").fetchone() + finally: + con.close() + except sqlite3.Error as exc: + raise BackupError("The database in this backup can't be opened, so nothing was restored.") from exc + if not row or row[0] != "ok": + raise BackupError("The database in this backup is damaged, so nothing was restored.") + + +def database_version(db: Path) -> int: + con = sqlite3.connect(db) + try: + return int(con.execute("PRAGMA user_version").fetchone()[0]) + finally: + con.close() + + +_COUNTS = { + "chats": "SELECT COUNT(*) FROM sessions", + "memories": "SELECT COUNT(*) FROM facts WHERE status = 'active'", + "tasks": "SELECT COUNT(*) FROM tasks", +} + + +def _counts(db: Path, home: Path) -> dict[str, int]: + out: dict[str, int] = {} + con = sqlite3.connect(db) + try: + for key, sql in _COUNTS.items(): + with contextlib.suppress(sqlite3.Error): + out[key] = int(con.execute(sql).fetchone()[0]) + finally: + con.close() + skills = home / "skills" + if skills.is_dir(): + out["skills"] = sum(1 for _ in skills.rglob("SKILL.md")) + return out + + +# ----------------------------------------------------------------------------- writing +def create_archive( + dest: Path, + *, + kind: str, + reason: str | None = None, + home: Path | None = None, + db_path: Path | None = None, + include_files: bool = True, + sign_ins: list[dict] | None = None, +) -> dict: + """Write one backup zip to ``dest`` and return its entry (see :func:`entry`).""" + home = home or paths.home() + db_path = db_path or (home / DB_NAME) + dest.parent.mkdir(parents=True, exist_ok=True) + skip_roots = [backups_root(home), dest.parent] + dirs = [d for d in DIRS if include_files or d != "files"] + part = dest.with_name(dest.name + ".part") + skipped: list[str] = [] + try: + with tempfile.TemporaryDirectory(prefix="sentient-backup-") as work: + contents: list[str] = [] + db_copy = Path(work) / DB_NAME + counts: dict[str, int] = {} + schema = None + if db_path.exists(): + copy_database(db_path, db_copy) + contents.append(DB_NAME) + counts = _counts(db_copy, home) + schema = database_version(db_copy) + config = home / CONFIG_NAME + if config.is_file(): + contents.append(CONFIG_NAME) + contents += dirs + manifest = { + "format": FORMAT, + "kind": kind, + "reason": reason, + "created_at": datetime.now(UTC).isoformat(), + "version": __version__, + "schema_version": schema, + "contents": contents, + "counts": counts, + "sign_ins": sign_ins or [], + "skipped": skipped, + } + with zipfile.ZipFile(part, "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True) as zf: + if DB_NAME in contents: + zf.write(db_copy, DB_NAME) + if CONFIG_NAME in contents: + zf.write(config, CONFIG_NAME) + for d in dirs: + root = home / d + if not root.is_dir(): + continue + for p in sorted(root.rglob("*")): + if p.is_symlink() or not p.is_file() or any(_inside(p, s) for s in skip_roots): + continue + rel = p.relative_to(home).as_posix() + try: + zf.write(p, rel) + except OSError as exc: # a file another program holds open + log.warning("backup skipped %s: %s", rel, exc) + skipped.append(rel) + manifest["skipped"] = skipped + zf.writestr(MANIFEST, json.dumps(manifest, indent=2, ensure_ascii=False)) + os.replace(part, dest) + finally: + part.unlink(missing_ok=True) + return entry(dest, manifest) + + +def create_snapshot(reason: str, *, keep: int, home: Path | None = None, db_path: Path | None = None) -> dict: + """A backup without files/ in ``/backups/snapshots``; only the newest ``keep`` are kept.""" + folder = snapshots_dir(home) + made = create_archive( + _unique(folder, SNAPSHOT_PREFIX), kind="snapshot", reason=reason, home=home, db_path=db_path, + include_files=False, + ) + prune(folder, keep, kind="snapshot") + return made + + +def new_backup_path(folder: Path, kind: str = "manual") -> Path: + return _unique(folder, BACKUP_PREFIX, "-auto" if kind == "scheduled" else "") + + +# ----------------------------------------------------------------------------- reading +def read_manifest(path: Path) -> dict: + if not path.is_file(): + raise BackupError("That backup file isn't there.") + try: + with zipfile.ZipFile(path) as zf: + raw = zf.read(MANIFEST) + except KeyError as exc: + raise BackupError("That file isn't a Sentient backup.") from exc + except (zipfile.BadZipFile, OSError) as exc: + raise BackupError("That file isn't a Sentient backup, or it is damaged.") from exc + try: + manifest = json.loads(raw) + except ValueError as exc: + raise BackupError("That backup is damaged.") from exc + if not isinstance(manifest, dict) or not isinstance(manifest.get("contents"), list): + raise BackupError("That file isn't a Sentient backup.") + if manifest.get("format") != FORMAT: + raise BackupError("That backup was made by a different version of Sentient that this one can't read.") + return manifest + + +def entry(path: Path, manifest: dict) -> dict: + try: + size = path.stat().st_size + except OSError: + size = 0 + return { + "name": path.name, + "path": str(path), + "kind": manifest.get("kind") if manifest.get("kind") in KINDS else "manual", + "reason": manifest.get("reason"), + "created_at": manifest.get("created_at"), + "version": manifest.get("version"), + "schema_version": manifest.get("schema_version"), + "size": size, + "includes_files": "files" in manifest.get("contents", []), + "counts": manifest.get("counts") or {}, + "sign_ins": manifest.get("sign_ins") or [], + } + + +def list_archives(folder: Path) -> list[dict]: + """Backups and snapshots in ``folder``, newest first. Files that aren't Sentient backups are left out.""" + out = [] + if not folder.is_dir(): + return out + for p in folder.glob("sentient-*.zip"): + if not p.name.startswith((BACKUP_PREFIX, SNAPSHOT_PREFIX)): + continue + try: + out.append(entry(p, read_manifest(p))) + except BackupError: + continue + out.sort(key=lambda e: e.get("created_at") or "", reverse=True) + return out + + +def prune(folder: Path, keep: int, *, kind: str) -> list[str]: + """Delete the oldest archives of one kind beyond ``keep``. Returns the names deleted.""" + gone = [] + for e in [e for e in list_archives(folder) if e["kind"] == kind][max(keep, 1):]: + with contextlib.suppress(OSError): + Path(e["path"]).unlink() + gone.append(e["name"]) + return gone + + +# ----------------------------------------------------------------------------- restoring +def stage_restore(archive: Path, home: Path | None = None) -> dict: + """Unpack and check a backup into the pending folder. Nothing in use changes until the engine restarts.""" + home = home or paths.home() + manifest = read_manifest(archive) + contents = [c for c in manifest["contents"] if c in ITEMS] + if DB_NAME not in contents: + raise BackupError("That backup has no database in it, so there is nothing to restore.") + pending = pending_dir(home) + work = pending.with_name(PENDING + ".part") + shutil.rmtree(work, ignore_errors=True) + work.mkdir(parents=True) + try: + _unpack(archive, work, contents) + check_database(work / DB_NAME) + staged = {**manifest, "contents": contents, "source": str(archive), "staged_at": datetime.now(UTC).isoformat()} + (work / MANIFEST).write_text(json.dumps(staged, indent=2, ensure_ascii=False), encoding="utf-8") + shutil.rmtree(pending, ignore_errors=True) + os.replace(work, pending) + except BaseException: + shutil.rmtree(work, ignore_errors=True) + raise + return staged + + +_ODD_PLACE = "This backup has a file in an unexpected place, so nothing was restored." + + +def _unpack(archive: Path, work: Path, contents: list[str]) -> None: + """Extract only the listed items, refusing any name that could land outside ``work``.""" + try: + with zipfile.ZipFile(archive) as zf: + for info in zf.infolist(): + name = info.filename + if name == MANIFEST: + continue + parts = PurePosixPath(name).parts + if ( + not parts or "\\" in name or name.startswith("/") or ".." in parts + or parts[0] not in contents or (len(parts) > 1 and parts[0] in (DB_NAME, CONFIG_NAME)) + ): + raise BackupError(_ODD_PLACE) + target = work.joinpath(*parts) + if not _inside(target, work): + raise BackupError(_ODD_PLACE) + if info.is_dir(): + target.mkdir(parents=True, exist_ok=True) + continue + target.parent.mkdir(parents=True, exist_ok=True) + with zf.open(info) as src, open(target, "wb") as dst: + shutil.copyfileobj(src, dst) + except zipfile.BadZipFile as exc: + raise BackupError("That backup is damaged, so nothing was restored.") from exc + if not (work / DB_NAME).is_file(): + raise BackupError("That backup has no database in it, so there is nothing to restore.") + + +def pending_restore(home: Path | None = None) -> dict | None: + f = pending_dir(home) / MANIFEST + if not f.is_file(): + return None + try: + data = json.loads(f.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + return data if isinstance(data, dict) else None + + +def cancel_restore(home: Path | None = None) -> bool: + pending = pending_dir(home) + if not pending.exists(): + return False + shutil.rmtree(pending, ignore_errors=True) + return True + + +def _item_names(item: str) -> list[str]: + return [DB_NAME, *(DB_NAME + s for s in DB_SIDECARS)] if item == DB_NAME else [item] + + +def _move(src: Path, dst: Path, tries: int = 6) -> None: + """os.replace, retried briefly: on Windows a just-stopped engine or a virus scanner can hold a file a moment.""" + for attempt in range(tries): + try: + os.replace(src, dst) + return + except PermissionError: + if attempt == tries - 1: + raise + time.sleep(0.5) + + +def _remove(path: Path) -> None: + if path.is_dir() and not path.is_symlink(): + shutil.rmtree(path) + elif path.exists() or path.is_symlink(): + path.unlink() + + +def apply_pending_restore(home: Path | None = None, *, snapshot_keep: int = 5) -> dict | None: + """Swap a staged restore into place. Call before anything opens the database or reads config.yaml. + + Returns the result written to ``backups/last-restore.json`` (also on failure, with ``error``), or None when + nothing was staged.""" + home = home or paths.home() + pending = pending_dir(home) + staged = pending_restore(home) + if staged is None: + if pending.exists(): # half-written: never apply it + shutil.rmtree(pending, ignore_errors=True) + return None + contents = [c for c in staged.get("contents", []) if c in ITEMS] + result: dict[str, Any] = { + "restored_at": datetime.now(UTC).isoformat(), + "source": staged.get("source"), + "kind": staged.get("kind"), + "backup_created_at": staged.get("created_at"), + "contents": contents, + "sign_ins": staged.get("sign_ins") or [], + "snapshot": None, + "error": None, + } + try: + if (home / DB_NAME).exists(): + try: + result["snapshot"] = create_snapshot("before restoring a backup", keep=snapshot_keep, home=home)["name"] + except Exception as exc: + log.exception("could not take a snapshot before restoring") + raise BackupError( + "The restore didn't happen, because Sentient couldn't save a copy of your current data first." + ) from exc + old = backups_root(home) / "restore-old" + shutil.rmtree(old, ignore_errors=True) + old.mkdir(parents=True) + moved: list[str] = [] + placed: list[str] = [] + try: + for item in contents: + for name in _item_names(item): + cur = home / name + if cur.exists(): + _move(cur, old / name) + moved.append(name) + for item in contents: + src = pending / item + if src.exists(): + _move(src, home / item) + placed.append(item) + except OSError as exc: + log.exception("restore failed, putting the current data back") + for item in placed: + with contextlib.suppress(OSError): + _remove(home / item) + for name in moved: + with contextlib.suppress(OSError): + _move(old / name, home / name) + raise BackupError( + "The restore didn't finish, so your data was left as it was. Close any program using Sentient's " + "folder and try again." + ) from exc + shutil.rmtree(old, ignore_errors=True) + log.warning("restored a backup made %s from %s", staged.get("created_at"), staged.get("source")) + except BackupError as exc: + result["error"] = str(exc) + finally: + shutil.rmtree(pending, ignore_errors=True) + out = backups_root(home) / LAST_RESTORE + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8") + return result + + +def last_restore(home: Path | None = None) -> dict | None: + f = backups_root(home) / LAST_RESTORE + try: + data = json.loads(f.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + return data if isinstance(data, dict) else None + + +def dismiss_last_restore(home: Path | None = None) -> None: + (backups_root(home) / LAST_RESTORE).unlink(missing_ok=True) diff --git a/sentient/backup/service.py b/sentient/backup/service.py new file mode 100644 index 00000000..143da6b7 --- /dev/null +++ b/sentient/backup/service.py @@ -0,0 +1,231 @@ +"""Back up now, restore, snapshots and scheduled backups (issue #151, docs/API.md section 20). + +The file work is in ``archive.py`` and runs in a thread. This service adds what needs the running app: the folder +and schedule from config, the sign-ins to list (they live in the keychain, never in a backup), events for the window, +and one backup or restore at a time. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +from sentient import paths, secrets +from sentient.backup import archive +from sentient.backup.archive import BackupError +from sentient.services import Service + +log = logging.getLogger(__name__) + +SCHEDULE_META = "backup.last_scheduled" +SCHEDULE_EVERY = {"daily": timedelta(days=1), "weekly": timedelta(days=7)} +CHECK_EVERY_S = 3600 +BROWSER_SIGN_IN = {"kind": "browser", "id": "browser", "label": "Websites you signed in to in Sentient's browser"} + + +class BackupService(Service): + name = "backups" + + def __init__(self, app): + super().__init__(app) + self._lock = asyncio.Lock() + + async def start(self) -> None: + if self.app.enable_background: + self.run_every(CHECK_EVERY_S, self._scheduled, name="scheduled", initial_delay=300) + + # ------------------------------------------------------------------ settings + def folder(self) -> Path: + raw = (self.app.config.backups.folder or "").strip() + return Path(raw).expanduser() if raw else archive.backups_root() + + def _publish(self, what: str, **extra: Any) -> None: + self.app.bus.publish("backup.updated", {"what": what, **extra}) + + # ------------------------------------------------------------------ sign-ins (never in a backup) + async def sign_ins(self) -> list[dict]: + """Everything signed in through the keychain right now: the list a restore elsewhere has to redo.""" + from sentient.gateway.routes.models import PROVIDERS + + out: list[dict] = [] + for p in PROVIDERS: + if p.get("key_required") and secrets.get_secret(p["id"]): + out.append({"kind": "model", "id": p["id"], "label": p["label"]}) + try: + for i in await self.app.integrations.list_integrations(): + plugin = self.app.integrations.plugin(i["id"]) + if i.get("connected") and plugin is not None and not plugin.is_builtin: + out.append({"kind": "integration", "id": i["id"], "label": i["display_name"]}) + except Exception: + log.exception("could not list integrations for the backup") + try: + for c in await self.app.channels.list(): + if await self.app.channels.is_connected(c["id"]): + out.append({"kind": "channel", "id": c["id"], "label": c["display_name"]}) + except Exception: + log.exception("could not list messaging apps for the backup") + browser = paths.home() / "browser" + if browser.is_dir() and any(browser.iterdir()): + out.append(dict(BROWSER_SIGN_IN)) + return out + + async def _signed_in(self, item: dict) -> bool | None: + """Whether a listed sign-in works on this computer now; None when Sentient can't tell.""" + kind, ident = item.get("kind"), str(item.get("id") or "") + try: + if kind == "model": + return bool(secrets.get_secret(ident)) + if kind == "integration": + return await self.app.integrations.is_connected(ident) + if kind == "channel": + return await self.app.channels.is_connected(ident) + except Exception: + return None + return None + + # ------------------------------------------------------------------ overview + async def overview(self) -> dict: + folder = self.folder() + backups = await asyncio.to_thread(archive.list_archives, folder) + snapshots = await asyncio.to_thread(archive.list_archives, archive.snapshots_dir()) + cfg = self.app.config.backups + return { + "folder": str(folder), + "default_folder": str(archive.backups_root()), + "backups": [b for b in backups if b["kind"] != "snapshot"], + "snapshots": [s for s in snapshots if s["kind"] == "snapshot"], + "schedule": cfg.schedule, + "keep": cfg.keep, + "snapshots_keep": cfg.snapshots_keep, + "last_scheduled_at": await self.app.store.get_meta(SCHEDULE_META), + "pending_restore": self._pending(), + "last_restore": await self.last_restore(), + } + + def _pending(self) -> dict | None: + staged = archive.pending_restore() + if staged is None: + return None + return {"source": staged.get("source"), "created_at": staged.get("created_at"), + "staged_at": staged.get("staged_at"), "kind": staged.get("kind")} + + async def last_restore(self) -> dict | None: + result = archive.last_restore() + if result is None: + return None + items = [] + for item in result.get("sign_ins") or []: + if isinstance(item, dict): + items.append({**item, "signed_in": await self._signed_in(item)}) + return {**result, "sign_ins": items} + + # ------------------------------------------------------------------ backing up + async def backup_now(self, folder: str | None = None, *, kind: str = "manual") -> dict: + target = Path(folder).expanduser() if folder and folder.strip() else self.folder() + if not target.is_absolute(): + raise BackupError("Pick a full folder path for the backup.") + sign_ins = await self.sign_ins() + async with self._lock: + try: + made = await asyncio.to_thread( + archive.create_archive, archive.new_backup_path(target, kind), kind=kind, + db_path=self.app.store.path, sign_ins=sign_ins, + ) + except PermissionError as exc: + raise BackupError("Sentient can't write to that folder. Pick another one.") from exc + except OSError as exc: + raise BackupError(f"The backup couldn't be written: {exc.strerror or exc}.") from exc + if kind == "scheduled": + await asyncio.to_thread(archive.prune, target, self.app.config.backups.keep, kind="scheduled") + self._publish("backup", name=made["name"]) + return made + + async def snapshot(self, reason: str, *, db_path: Path | None = None) -> dict | None: + """Take a snapshot now (before a database update or an import). Never raises; None when it failed.""" + try: + async with self._lock: + made = await asyncio.to_thread( + archive.create_snapshot, reason, keep=self.app.config.backups.snapshots_keep, + db_path=db_path or self.app.store.path, + ) + except Exception: + log.exception("could not take a snapshot (%s)", reason) + return None + log.info("snapshot %s taken %s", made["name"], reason) + self._publish("snapshot", name=made["name"]) + return made + + async def _scheduled(self) -> None: + every = SCHEDULE_EVERY.get(self.app.config.backups.schedule) + if every is None: + return + last = await self.app.store.get_meta(SCHEDULE_META) + now = datetime.now(UTC) + if last: + try: + if now - datetime.fromisoformat(last) < every: + return + except ValueError: + pass + try: + await self.backup_now(kind="scheduled") + except BackupError as exc: + log.warning("scheduled backup failed: %s", exc) + await self.app.notify("backup", f"The automatic backup didn't work: {exc}", title="Backup") + await self.app.store.set_meta(SCHEDULE_META, now.isoformat()) + + # ------------------------------------------------------------------ restoring + async def inspect(self, path: str) -> dict: + """What a backup holds, for the confirmation before restoring.""" + p = Path(path).expanduser() + manifest = await asyncio.to_thread(archive.read_manifest, p) + from sentient.store.db import SCHEMA_VERSION + + made = archive.entry(p, manifest) + newer = (manifest.get("schema_version") or 0) > SCHEMA_VERSION + return {**made, "newer": newer, "contents": manifest.get("contents") or []} + + async def restore(self, path: str) -> dict: + """Check and stage a backup. It replaces the current data when the engine restarts.""" + p = Path(path).expanduser() + async with self._lock: + staged = await asyncio.to_thread(archive.stage_restore, p) + log.warning("restore staged from %s", p) + self._publish("restore_staged", source=str(p)) + return { + "staged": True, + "restart_required": True, + "source": str(p), + "created_at": staged.get("created_at"), + "contents": staged.get("contents"), + "sign_ins": staged.get("sign_ins") or [], + } + + async def cancel_restore(self) -> bool: + async with self._lock: + done = await asyncio.to_thread(archive.cancel_restore) + if done: + self._publish("restore_cancelled") + return done + + def dismiss_last_restore(self) -> None: + archive.dismiss_last_restore() + self._publish("restore_dismissed") + + +def apply_pending_restore() -> dict | None: + """Run at engine start, before the config and database are opened (gateway lifespan and ``sentient restore``).""" + from sentient.config import load_config + + try: + keep = load_config().backups.snapshots_keep + except Exception: + keep = 5 + result = archive.apply_pending_restore(snapshot_keep=keep) + if result is not None: + log.warning("restore result: %s", json.dumps({k: result[k] for k in ("source", "error", "snapshot")})) + return result diff --git a/sentient/cli.py b/sentient/cli.py index daf86dc6..c64aa214 100644 --- a/sentient/cli.py +++ b/sentient/cli.py @@ -106,6 +106,45 @@ def claude_code_tools(tools_file: str): raise typer.Exit(main([tools_file])) +@app.command() +def backup( + to: Path | None = typer.Option(None, "--to", help="Folder for the backup (default: backups in the data folder)."), +): + """Back up the data folder (database, config, workspace, skills, files) to one zip file.""" + from sentient.backup import archive + + folder = to or Path(load_config().backups.folder or archive.backups_root()).expanduser() + made = archive.create_archive(archive.new_backup_path(folder), kind="manual") + console.print(f"[green]backed up[/green] {made['path']} ({made['size']:,} bytes)") + console.print("API keys and sign-ins stay in this computer's keychain and are not in the backup.") + + +@app.command() +def restore( + archive_path: Path = typer.Argument(..., help="A backup or snapshot zip."), + now: bool = typer.Option(False, "--now", help="Replace the data at once. Only while Sentient is closed."), +): + """Restore a backup. It replaces the data the next time Sentient starts (or at once with --now).""" + from sentient.backup import BackupError, apply_pending_restore, archive + + try: + staged = archive.stage_restore(archive_path) + except BackupError as exc: + console.print(f"[red]{exc}[/red]") + raise typer.Exit(1) from exc + console.print(f"backup from {staged.get('created_at')} checked: {', '.join(staged['contents'])}") + if not now: + console.print("It replaces your current data the next time Sentient starts.") + return + result = apply_pending_restore() or {} + if result.get("error"): + console.print(f"[red]{result['error']}[/red]") + raise typer.Exit(1) + kept = f"; your data from before is in snapshot {result['snapshot']}" if result.get("snapshot") else "" + console.print(f"[green]restored[/green]{kept}") + console.print("API keys and sign-ins are not in backups: sign in again to anything that asks.") + + @app.command() def doctor( models: bool = typer.Option( diff --git a/sentient/config/schema.py b/sentient/config/schema.py index 7c2881bc..2b2cfaa3 100644 --- a/sentient/config/schema.py +++ b/sentient/config/schema.py @@ -977,6 +977,27 @@ class DreamingConfig(BaseModel): notify: bool = Field(True, description="Send a notification when a dream changed something meaningful.") +class BackupsConfig(BaseModel): + folder: str | None = Field( + None, + description="Folder for backups. Empty uses the backups folder inside Sentient's data folder. A folder on " + "another drive or a synced cloud folder keeps them safe if this computer fails.", + ) + schedule: Literal["off", "daily", "weekly"] = Field( + "off", description="Back up automatically every day or every week, while Sentient is running." + ) + keep: int = Field( + 5, ge=1, le=100, + description="How many automatic backups to keep. Older automatic ones are deleted; backups you make yourself " + "are never deleted.", + ) + snapshots_keep: int = Field( + 5, ge=1, le=50, + description="How many snapshots to keep. Sentient takes one before it updates its database, before an import " + "and before a restore.", + ) + + class SentientConfig(BaseModel): """Root configuration, persisted at ``~/.sentient/config.yaml``.""" @@ -1000,4 +1021,5 @@ class SentientConfig(BaseModel): channels: ChannelsConfig = Field(default_factory=ChannelsConfig) user_model: UserModelConfig = Field(default_factory=UserModelConfig) dreaming: DreamingConfig = Field(default_factory=DreamingConfig) + backups: BackupsConfig = Field(default_factory=BackupsConfig) ui: UIConfig = Field(default_factory=UIConfig) diff --git a/sentient/gateway/app.py b/sentient/gateway/app.py index e0a12350..67eb0d82 100644 --- a/sentient/gateway/app.py +++ b/sentient/gateway/app.py @@ -27,6 +27,7 @@ from sentient import __version__ from sentient.app import SentientApp +from sentient.backup import apply_pending_restore from sentient.gateway.auth import load_or_create_token, ws_token_ok from sentient.gateway.routes import ROUTERS @@ -46,6 +47,9 @@ async def lifespan(app: FastAPI): # SENTIENT_DISABLE_BACKGROUND=1 starts the engine without schedulers, pollers, resume or reviews # (demo/screenshot profiles, debugging). The desktop app never sets it. background = os.environ.get("SENTIENT_DISABLE_BACKGROUND", "").strip().lower() not in {"1", "true", "yes"} + if sentient is None: + # a restore staged before the restart replaces the data now, before config and database are opened + apply_pending_restore() app.state.sentient = sentient or SentientApp(enable_background=background) await app.state.sentient.start() yield diff --git a/sentient/gateway/routes/__init__.py b/sentient/gateway/routes/__init__.py index 3feb8d51..40a59610 100644 --- a/sentient/gateway/routes/__init__.py +++ b/sentient/gateway/routes/__init__.py @@ -12,9 +12,11 @@ user_model : memory package (user model and dreams) hooks : integrations package (inbound webhooks) migrate : core (importing from Hermes) +backups : core (backup, restore and snapshots) """ from sentient.gateway.routes import ( + backups, browser, channels, core, @@ -54,4 +56,5 @@ user_model.router, hooks.router, migrate.router, + backups.router, ] diff --git a/sentient/gateway/routes/backups.py b/sentient/gateway/routes/backups.py new file mode 100644 index 00000000..c6a6e83e --- /dev/null +++ b/sentient/gateway/routes/backups.py @@ -0,0 +1,59 @@ +"""REST routes for backups, restore and snapshots (owner: core). Contract: docs/API.md section 20.""" + +from __future__ import annotations + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel + +from sentient.backup import BackupError +from sentient.gateway.deps import AUTH, get_core + +router = APIRouter(prefix="/api/backups", tags=["backups"], dependencies=AUTH) + + +class BackupBody(BaseModel): + folder: str | None = None + + +class PathBody(BaseModel): + path: str + + +@router.get("") +async def overview(request: Request): + return await get_core(request).backups.overview() + + +@router.post("") +async def backup_now(request: Request, body: BackupBody | None = None): + try: + return await get_core(request).backups.backup_now(body.folder if body else None) + except BackupError as exc: + raise HTTPException(400, str(exc)) from exc + + +@router.post("/inspect") +async def inspect(request: Request, body: PathBody): + try: + return await get_core(request).backups.inspect(body.path) + except BackupError as exc: + raise HTTPException(400, str(exc)) from exc + + +@router.post("/restore") +async def restore(request: Request, body: PathBody): + try: + return await get_core(request).backups.restore(body.path) + except BackupError as exc: + raise HTTPException(400, str(exc)) from exc + + +@router.delete("/restore") +async def cancel_restore(request: Request): + return {"cancelled": await get_core(request).backups.cancel_restore()} + + +@router.delete("/last-restore") +async def dismiss_last_restore(request: Request): + get_core(request).backups.dismiss_last_restore() + return {"ok": True} diff --git a/sentient/migrate/hermes.py b/sentient/migrate/hermes.py index 441e8785..c4f43f2c 100644 --- a/sentient/migrate/hermes.py +++ b/sentient/migrate/hermes.py @@ -773,6 +773,9 @@ async def apply(app: Any, path: str | None, parts: list[str] | None, skip: list[ plan = await preview(app, path) home = Path(plan["path"]) left_out = set(skip or []) + backups = getattr(app, "backups", None) + if backups is not None: # an import can be undone by restoring this snapshot (issue #151) + await backups.snapshot("before importing from Hermes") result: dict[str, Any] = {"path": plan["path"]} if "skills" in chosen: diff --git a/sentient/store/db.py b/sentient/store/db.py index d30b47c5..0b8c0aad 100644 --- a/sentient/store/db.py +++ b/sentient/store/db.py @@ -6,9 +6,12 @@ from __future__ import annotations +import asyncio import json +import logging +import sqlite3 import uuid -from collections.abc import Iterable +from collections.abc import Awaitable, Callable, Iterable from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -17,9 +20,14 @@ from sentient import paths +log = logging.getLogger(__name__) + _SCHEMA = Path(__file__).with_name("schema.sql") _PKG_ROOT = Path(__file__).resolve().parents[1] # Feature packages own their tables. Each file must be idempotent. Order matters for foreign keys. +# Bump when a change to a schema.sql or an ensure_column needs existing databases updated. Opening a database +# with another version takes a snapshot first (issue #151); so does the first column added to an existing one. +SCHEMA_VERSION = 1 PACKAGE_SCHEMAS = ["tasks", "integrations", "proactivity", "evolution", "voice", "memory", "nodes", "channels", "browser", "sandbox"] @@ -46,15 +54,43 @@ async def _load_vec(db: aiosqlite.Connection) -> bool: return False +def _peek_version(path: Path) -> tuple[bool, int]: + """(has tables, user_version) of a database file, before anything changes it.""" + if not path.exists(): + return False, 0 + con = sqlite3.connect(path, timeout=30) + try: + tables = con.execute("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table'").fetchone()[0] + return bool(tables), int(con.execute("PRAGMA user_version").fetchone()[0]) + finally: + con.close() + + class Store: - def __init__(self, path: Path | None = None): + def __init__(self, path: Path | None = None, *, before_migration: Callable[[str], Awaitable[None]] | None = None): self.path = path or paths.db_file() self._db: aiosqlite.Connection | None = None self.vec_available = False + # called once, before the first change to an existing database (a snapshot, issue #151) + self.before_migration = before_migration + self._existing = False + self._snapshot_taken = False + + async def _before_migration(self, reason: str) -> None: + if not self._existing or self._snapshot_taken or self.before_migration is None: + return + self._snapshot_taken = True + try: + await self.before_migration(reason) + except Exception: # a failed snapshot never keeps Sentient from starting + log.exception("could not take a snapshot before updating the database") # ------------------------------------------------------------------ lifecycle async def open(self) -> Store: self.path.parent.mkdir(parents=True, exist_ok=True) + self._existing, version = await asyncio.to_thread(_peek_version, self.path) + if self._existing and version != SCHEMA_VERSION: + await self._before_migration(f"before updating the database (version {version} to {SCHEMA_VERSION})") self._db = await aiosqlite.connect(self.path) self._db.row_factory = aiosqlite.Row self.vec_available = await _load_vec(self._db) @@ -79,6 +115,7 @@ async def open(self) -> Store: extra = _PKG_ROOT / pkg / "schema.sql" if extra.exists(): await self._db.executescript(extra.read_text(encoding="utf-8")) + await self._db.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") await self._db.commit() return self @@ -111,6 +148,7 @@ async def ensure_column(self, table: str, column: str, decl: str) -> None: async with self.db.execute(f"PRAGMA table_info({table})") as cur: cols = {r[1] for r in await cur.fetchall()} if cols and column not in cols: + await self._before_migration(f"before adding {column} to {table}") await self.db.execute(f"ALTER TABLE {table} ADD COLUMN {column} {decl}") async def get_meta(self, key: str) -> str | None: diff --git a/tests/test_backup.py b/tests/test_backup.py new file mode 100644 index 00000000..f2a422f7 --- /dev/null +++ b/tests/test_backup.py @@ -0,0 +1,295 @@ +"""Backup, restore and snapshots of the data folder (issue #151, docs/API.md section 20).""" + +from __future__ import annotations + +import asyncio +import json +import sqlite3 +import zipfile +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from sentient import paths, secrets +from sentient.app import SentientApp +from sentient.backup import BackupError, apply_pending_restore, archive +from sentient.gateway.app import create_app +from sentient.migrate import hermes +from sentient.store.db import SCHEMA_VERSION +from tests.conftest import FakeProvider + + +@pytest.fixture(autouse=True) +def keychain(monkeypatch) -> dict[str, str]: + """In-memory keychain: a backup lists sign-ins, it never reads the real one.""" + store: dict[str, str] = {} + monkeypatch.setattr(secrets, "get_secret", lambda name, env_var=None: store.get(name)) + monkeypatch.setattr(secrets, "set_secret", lambda name, value: store.__setitem__(name, value) or True) + return store + + +async def _app(config) -> SentientApp: + return await SentientApp(config, llm=FakeProvider(), enable_background=False).start() + + +async def _seed(app: SentientApp) -> dict: + fact = await app.memory.remember("Maya Rao's sister Priya lives in Lisbon", use_llm=False) + sid = await app.store.create_session(title="Trip planning") + await app.store.add_message(sid, "user", "Book the Lisbon trip for March") + task = await app.tasks.create_task("Draft the weekly product update") + (paths.files_dir() / "notes.txt").write_text("budget v1", encoding="utf-8") + return {"fact": fact["id"], "session": sid, "task": task["task_id"]} + + +async def test_backup_restores_an_identical_working_state(config, isolated_home, keychain): + keychain["openai"] = "sk-test" + app = await _app(config) + seeded = await _seed(app) + made = await app.backups.backup_now() + assert made["kind"] == "manual" and made["includes_files"] + assert made["counts"]["chats"] == 1 and made["counts"]["memories"] == 1 and made["counts"]["tasks"] == 1 + assert {"kind": "model", "id": "openai", "label": "OpenAI"} in made["sign_ins"] + with zipfile.ZipFile(made["path"]) as zf: + names = set(zf.namelist()) + assert {"manifest.json", "sentient.db", "files/notes.txt", "workspace/SOUL.md"} <= names + assert not any(n.startswith(("backups/", "logs/")) or n.endswith(("-wal", "-shm", "gateway.token")) for n in names) + + # lose and change things after the backup + await app.memory.forget(seeded["fact"]) + await app.store.delete_session(seeded["session"]) + await app.tasks.delete(seeded["task"]) + (paths.files_dir() / "notes.txt").write_text("budget v2", encoding="utf-8") + (paths.files_dir() / "later.txt").write_text("made after the backup", encoding="utf-8") + staged = await app.backups.restore(made["path"]) + assert staged["restart_required"] and "sentient.db" in staged["contents"] + assert (await app.backups.overview())["pending_restore"]["source"] == made["path"] + await app.stop() + + keychain.clear() # as on a new computer: the key is not in the backup + result = apply_pending_restore() + assert result["error"] is None and result["snapshot"].startswith("sentient-snapshot-") + assert not archive.pending_dir().exists() + + app = await _app(config) + try: + facts = await app.memory.list_facts() + assert [f["content"] for f in facts] == ["Maya Rao's sister Priya lives in Lisbon"] + assert await app.store.get_session(seeded["session"]) is not None + assert (await app.store.recent_messages(seeded["session"], 5))[0]["content"] == "Book the Lisbon trip for March" + assert (await app.tasks.get(seeded["task"]))["description"] == "Draft the weekly product update" + assert (paths.files_dir() / "notes.txt").read_text(encoding="utf-8") == "budget v1" + assert not (paths.files_dir() / "later.txt").exists() + overview = await app.backups.overview() + last = overview["last_restore"] + assert last["source"] == made["path"] + assert last["sign_ins"] == [{"kind": "model", "id": "openai", "label": "OpenAI", "signed_in": False}] + # the data from before the restore is a snapshot, so the restore can be undone + assert [s["reason"] for s in overview["snapshots"]] == ["before restoring a backup"] + assert not overview["snapshots"][0]["includes_files"] + finally: + await app.stop() + + +async def test_snapshot_before_a_schema_update_and_only_the_last_n_kept(config, isolated_home): + config.backups.snapshots_keep = 2 + app = await _app(config) # a new database: nothing to protect + await app.stop() + assert archive.list_archives(archive.snapshots_dir()) == [] + app = await _app(config) # same version again: no snapshot + await app.stop() + assert archive.list_archives(archive.snapshots_dir()) == [] + assert archive.database_version(paths.db_file()) == SCHEMA_VERSION + + for _ in range(3): # an older database (a previous build) each time + con = sqlite3.connect(paths.db_file()) + con.execute("PRAGMA user_version = 0") + con.commit() + con.close() + app = await _app(config) + await app.stop() + snaps = archive.list_archives(archive.snapshots_dir()) + assert len(snaps) == 2 + assert snaps[0]["reason"] == f"before updating the database (version 0 to {SCHEMA_VERSION})" + assert snaps[0]["schema_version"] == 0 # the copy is from before the update + assert archive.database_version(paths.db_file()) == SCHEMA_VERSION + + +async def test_snapshot_before_a_new_column_on_an_existing_database(config, isolated_home): + app = await _app(config) + await app.store.ensure_column("sessions", "colour", "TEXT") # what a later build adding a column does + await app.store.ensure_column("sessions", "shape", "TEXT") # one snapshot per start, not per column + await app.stop() + assert archive.list_archives(archive.snapshots_dir()) == [] # it was a new database + app = await _app(config) + try: + await app.store.ensure_column("sessions", "size", "TEXT") + await app.store.ensure_column("sessions", "weight", "TEXT") + snaps = archive.list_archives(archive.snapshots_dir()) + assert [s["reason"] for s in snaps] == ["before adding size to sessions"] + finally: + await app.stop() + + +async def test_hermes_import_takes_a_snapshot_first(config, isolated_home, tmp_path): + folder = tmp_path / "hermes" + (folder / "memories").mkdir(parents=True) + (folder / "memories" / "MEMORY.md").write_text("Maya likes window seats", encoding="utf-8") + app = await _app(config) + try: + await hermes.apply(app, str(folder), ["memory"]) + snaps = archive.list_archives(archive.snapshots_dir()) + assert [s["reason"] for s in snaps] == ["before importing from Hermes"] + finally: + await app.stop() + + +async def test_a_snapshot_restore_leaves_files_alone(config, isolated_home): + app = await _app(config) + (paths.files_dir() / "keep.txt").write_text("still here", encoding="utf-8") + snap = await app.backups.snapshot("before trying something") + sid = await app.store.create_session(title="After the snapshot") + await app.backups.restore(snap["path"]) + await app.stop() + assert apply_pending_restore()["error"] is None + app = await _app(config) + try: + assert await app.store.get_session(sid) is None + assert (paths.files_dir() / "keep.txt").read_text(encoding="utf-8") == "still here" + finally: + await app.stop() + + +def _zip(path: Path, entries: dict[str, bytes | str]) -> Path: + with zipfile.ZipFile(path, "w") as zf: + for name, data in entries.items(): + zf.writestr(name, data) + return path + + +def _manifest(contents=("sentient.db",)) -> str: + return json.dumps({"format": 1, "kind": "manual", "contents": list(contents), "created_at": "2026-10-11T10:00:00"}) + + +def test_bad_archives_are_refused_and_nothing_is_staged(tmp_path, isolated_home): + good_db = tmp_path / "ok.db" + con = sqlite3.connect(good_db) + con.execute("CREATE TABLE t(x)") + con.commit() + con.close() + cases = { + "not a zip": (tmp_path / "a.zip").write_bytes(b"hello") and tmp_path / "a.zip", + "no manifest": _zip(tmp_path / "b.zip", {"sentient.db": good_db.read_bytes()}), + "escapes": _zip(tmp_path / "c.zip", {"manifest.json": _manifest(), "sentient.db": good_db.read_bytes(), + "../evil.txt": "x"}), + "backslash": _zip(tmp_path / "d.zip", {"manifest.json": _manifest(("sentient.db", "files")), + "sentient.db": good_db.read_bytes(), "files/..\\..\\evil.txt": "x"}), + "not listed": _zip(tmp_path / "e.zip", {"manifest.json": _manifest(), "sentient.db": good_db.read_bytes(), + "logs/x.log": "x"}), + "damaged db": _zip(tmp_path / "f.zip", {"manifest.json": _manifest(), "sentient.db": b"not a database" * 100}), + "no db": _zip(tmp_path / "g.zip", {"manifest.json": _manifest(("config.yaml",)), "config.yaml": "x: 1"}), + "future format": _zip(tmp_path / "h.zip", {"manifest.json": json.dumps({"format": 9, "contents": []})}), + } + for label, path in cases.items(): + with pytest.raises(BackupError): + archive.stage_restore(path) + assert archive.pending_restore() is None, label + assert not (tmp_path / "evil.txt").exists() and not (isolated_home / "evil.txt").exists() + assert apply_pending_restore() is None + + +async def test_scheduled_backups_keep_the_last_n_and_never_touch_manual_ones(config, isolated_home, tmp_path): + folder = tmp_path / "elsewhere" + config.backups.folder = str(folder) + config.backups.schedule = "daily" + config.backups.keep = 2 + app = await _app(config) + try: + manual = await app.backups.backup_now() + await app.backups._scheduled() + await app.backups._scheduled() # not due yet + assert len([b for b in (await app.backups.overview())["backups"] if b["kind"] == "scheduled"]) == 1 + for _ in range(3): + await app.store.set_meta("backup.last_scheduled", "2020-01-01T00:00:00+00:00") + await app.backups._scheduled() + kinds = [b["kind"] for b in (await app.backups.overview())["backups"]] + assert kinds.count("scheduled") == 2 and kinds.count("manual") == 1 + assert Path(manual["path"]).exists() + config.backups.schedule = "off" + await app.store.set_meta("backup.last_scheduled", "2020-01-01T00:00:00+00:00") + await app.backups._scheduled() + assert len((await app.backups.overview())["backups"]) == 3 + finally: + await app.stop() + + +def test_backup_routes(config, isolated_home, monkeypatch, tmp_path): + monkeypatch.setenv("SENTIENT_GATEWAY_TOKEN", "test-token") + core = SentientApp(config, llm=FakeProvider(), enable_background=False) + with TestClient(create_app(core)) as c: + c.headers.update({"Authorization": "Bearer test-token"}) + first = c.get("/api/backups").json() + assert first["backups"] == [] and first["pending_restore"] is None and first["schedule"] == "off" + made = c.post("/api/backups", json={}).json() + assert made["name"].startswith("sentient-backup-") + elsewhere = c.post("/api/backups", json={"folder": str(tmp_path / "usb")}).json() + assert Path(elsewhere["path"]).parent == tmp_path / "usb" + assert c.post("/api/backups", json={"folder": "relative/folder"}).status_code == 400 + seen = c.post("/api/backups/inspect", json={"path": made["path"]}).json() + assert seen["contents"][0] == "sentient.db" and seen["newer"] is False + assert c.post("/api/backups/inspect", json={"path": str(tmp_path / "nope.zip")}).status_code == 400 + staged = c.post("/api/backups/restore", json={"path": made["path"]}).json() + assert staged["restart_required"] is True + assert c.get("/api/backups").json()["pending_restore"]["source"] == made["path"] + assert c.delete("/api/backups/restore").json() == {"cancelled": True} + assert c.get("/api/backups").json()["pending_restore"] is None + assert [b["name"] for b in c.get("/api/backups").json()["backups"]] == [made["name"]] + assert c.delete("/api/backups/last-restore").json() == {"ok": True} + + +def test_a_staged_restore_is_applied_when_the_engine_starts(config, isolated_home, monkeypatch): + monkeypatch.setenv("SENTIENT_GATEWAY_TOKEN", "test-token") + monkeypatch.setenv("SENTIENT_DISABLE_BACKGROUND", "1") + + async def setup(): + app = await _app(config) + made = await app.backups.backup_now() + await app.store.create_session(title="Made after the backup") + await app.backups.restore(made["path"]) + await app.stop() + + asyncio.run(setup()) + with TestClient(create_app()) as c: # the desktop's engine: lifespan applies the restore first + c.headers.update({"Authorization": "Bearer test-token"}) + assert c.get("/api/sessions").json() == [] + last = c.get("/api/backups").json()["last_restore"] + assert last["error"] is None and last["snapshot"] + + +async def test_a_failed_restore_puts_the_current_data_back(config, isolated_home, monkeypatch): + app = await _app(config) + (paths.files_dir() / "mine.txt").write_text("in the backup", encoding="utf-8") + made = await app.backups.backup_now() + sid = await app.store.create_session(title="Made after the backup") + (paths.files_dir() / "mine.txt").write_text("keep me", encoding="utf-8") + await app.backups.restore(made["path"]) + await app.stop() + + real = archive._move + + def flaky(src, dst, tries=6): + if Path(src).name == "files" and archive.PENDING in str(src): + raise PermissionError("in use") + real(src, dst, tries=1) + + monkeypatch.setattr(archive, "_move", flaky) + result = apply_pending_restore() + assert result["error"] and "left as it was" in result["error"] + assert not archive.pending_dir().exists() + app = await _app(config) + try: + assert await app.store.get_session(sid) is not None + assert (paths.files_dir() / "mine.txt").read_text(encoding="utf-8") == "keep me" + assert (await app.backups.overview())["last_restore"]["error"] == result["error"] + finally: + await app.stop() From ecbf3419001c227491ea4cc7ade0b4eb0f6a1f25 Mon Sep 17 00:00:00 2001 From: itsskofficial Date: Sun, 11 Oct 2026 12:03:25 +0530 Subject: [PATCH 02/10] fix(backup): retry a failed scheduled backup, keep data aside when a rollback fails --- docs/API.md | 5 +++-- sentient/backup/archive.py | 19 +++++++++++++--- sentient/backup/service.py | 24 ++++++++++++++------ tests/test_backup.py | 46 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 12 deletions(-) diff --git a/docs/API.md b/docs/API.md index 89ffa0c0..d60e30c0 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2085,7 +2085,7 @@ archive (manifest, names, SQLite `quick_check`) and unpacks it into `/back start applies it before config or database are opened (the desktop restarts the engine at once). Applying takes a snapshot of the current data, then replaces exactly the items the manifest lists (`contents`): a snapshot leaves `files/` alone, and an item listed but empty in the backup ends up empty. If anything fails the current data is put -back. The outcome is kept in `/backups/last-restore.json` until dismissed. +back; if even that fails, it stays in `/backups/restore-old-