Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 11 additions & 2 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -664,7 +664,11 @@ async def _stop_background():

# Admin Danger Zone wipes (Settings → System → Danger Zone)
from routes.admin_wipe.admin_wipe_routes import setup_admin_wipe_routes
app.include_router(setup_admin_wipe_routes(session_manager))
app.include_router(setup_admin_wipe_routes(
session_manager,
memory_manager=memory_manager,
memory_vector=memory_vector,
))

# Memory
from routes.memory.memory_routes import setup_memory_routes
Expand Down Expand Up @@ -796,7 +800,12 @@ async def _stop_background():

# Backup (export/import user data)
from routes.backup_routes import setup_backup_routes
app.include_router(setup_backup_routes(memory_manager, preset_manager, skills_manager))
app.include_router(setup_backup_routes(
memory_manager,
preset_manager,
skills_manager,
memory_vector=memory_vector,
))

from routes.font_routes import setup_font_routes
app.include_router(setup_font_routes())
Expand Down
4 changes: 4 additions & 0 deletions docs/backup-restore.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Backup & Restore

The Settings JSON export is a separate, mixed-scope portability format. Its version 2 shape includes memories, skills, presets, settings, preferences, calendars and events, scheduled tasks and run history, and notes. Memories, skills, preferences, calendars, tasks, and notes are selected for the current owner during export. Imported version 2 calendar, task, and note rows are stamped with the current owner, and primary-key collisions are remapped rather than overwriting another user's rows. Presets are shared across users, while settings and feature flags are instance-global; those sections remain shared/global rather than becoming owner-scoped. Task webhook tokens are intentionally excluded and must be regenerated after restore. References to chat/agent sessions, crew members, characters, and note upload images are detached because those domains are not part of the JSON format. Imported CalDAV calendars/events become local rows; reconnect and sync the account explicitly instead of reusing remote hrefs or pending-write markers from another instance.

Version 1 JSON exports remain importable; they simply do not contain the newer calendar/task/note sections. The commands below describe full on-disk instance snapshots, which serve a different disaster-recovery use case.

Odysseus keeps all of your state in the `data/` directory — the SQLite database
(`app.db`), the Fernet encryption key (`data/.app_key`), the vault, memory, RAG
indexes, personal documents, and uploads. The `scripts/odysseus-backup` tool
Expand Down
58 changes: 37 additions & 21 deletions routes/admin_wipe/admin_wipe_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
Kinds: chats, memory, skills, notes, tasks, documents, gallery, calendar.
"""

import json
import logging
import os
import shutil
Expand All @@ -36,19 +35,14 @@
logger = logging.getLogger(__name__)


def _wipe_memory_files():
"""Blank memory.json + drop the per-owner tidy-state sidecar so the
next audit doesn't try to diff against gone memories."""
for name in ("memory.json", "memory_tidy_state.json"):
def _wipe_memory_sidecars():
"""Drop derived memory state after the authoritative stores are empty."""
for name in ("memory_tidy_state.json",):
p = os.path.join(DATA_DIR, name)
if not os.path.exists(p):
continue
try:
if name == "memory.json":
with open(p, "w", encoding="utf-8") as f:
json.dump([], f)
else:
os.remove(p)
os.remove(p)
except OSError as e:
logger.warning(f"Could not reset {name}: {e}")

Expand All @@ -62,7 +56,7 @@ def _rmtree_quiet(path: str):
logger.warning(f"Could not remove {path}: {e}")


def setup_admin_wipe_routes(session_manager):
def setup_admin_wipe_routes(session_manager, memory_manager=None, memory_vector=None):
"""The session_manager is passed in so we can also clear its
in-memory cache when wiping chats — without it the DB is empty
but the next /api/sessions returns stale entries."""
Expand All @@ -87,20 +81,41 @@ def wipe(kind: str, request: Request):
return {"status": "deleted", "kind": kind, "count": count}

if kind == "memory":
if memory_manager is None or memory_vector is None:
raise HTTPException(503, "Memory stores are not available for a safe wipe")
if not getattr(memory_vector, "healthy", False):
raise HTTPException(503, "Memory vector store is unavailable; nothing was deleted")

original_memories = memory_manager.load_all_for_update()
count = db.query(Memory).count()
db.query(Memory).delete()
db.commit()
_wipe_memory_files()
# Drop the vector store too so semantic search doesn't
# return ghosts. Lazy import — chromadb may not be
# initialised in every deployment.

try:
from src.memory_vector import get_memory_vector_store
mv = get_memory_vector_store()
if mv and hasattr(mv, "clear"):
mv.clear()
# Clear vectors before committing SQL. Keep the clear in
# the compensation boundary because a backend can fail
# after deleting only some lanes.
memory_vector.clear(strict=True)
memory_manager.save([])
db.commit()
except Exception as e:
logger.info(f"Memory vector clear skipped: {e}")
# Restore the full multi-user corpus, never only the active
# request owner's slice. A failed compensation is surfaced
# because silently leaving split stores would be worse.
restore_errors = []
try:
memory_manager.save(original_memories)
except Exception as restore_error:
restore_errors.append(f"JSON restore failed: {restore_error}")
try:
memory_vector.rebuild(original_memories, strict=True)
except Exception as restore_error:
restore_errors.append(f"vector restore failed: {restore_error}")
if restore_errors:
raise RuntimeError(
f"Memory wipe failed ({e}); " + "; ".join(restore_errors)
) from e
raise
_wipe_memory_sidecars()
return {"status": "deleted", "kind": kind, "count": count}

if kind == "skills":
Expand Down Expand Up @@ -165,6 +180,7 @@ def wipe(kind: str, request: Request):

raise HTTPException(400, f"Unknown wipe kind: {kind!r}")
except HTTPException:
db.rollback()
raise
except Exception as e:
db.rollback()
Expand Down
Loading