Skip to content
Merged
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
4 changes: 4 additions & 0 deletions changes/440.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
**Backups from a test, demo or second data folder no longer default to your real Documents backups.** Only the
default data folder (`~/.sentient`) backs up to Documents now; any other `SENTIENT_HOME` keeps its backups in its own
`backups` folder unless you pick a folder. If such a data folder already had backups in Documents, they stay there; pick
that folder to see them again.
4 changes: 3 additions & 1 deletion desktop/src/features/settings/sections/Backups.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,9 @@ export function BackupsSection({ query }: SectionProps) {
{customFolder
? 'The folder you picked.'
: data.folder_in_home
? "Inside Sentient's data folder, because your Documents folder can't be used. Pick a folder elsewhere so a backup survives losing it."
? data.home_is_default
? "Inside Sentient's data folder, because your Documents folder can't be used. Pick a folder elsewhere so a backup survives losing it."
: 'Backups for this data folder stay inside it. Pick a folder elsewhere so a backup survives losing it.'
: 'In your Documents folder, apart from your data, so a backup survives losing it.'}
</span>
</>
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3227,6 +3227,8 @@ export interface BackupOverview {
home: string
/** The backup folder is inside the data folder (so a backup would not survive losing it). */
folder_in_home: boolean
/** The data folder is the default `~/.sentient`; any other one keeps its backups inside itself. */
home_is_default: boolean
/** The sync service the backup folder is inside (its backups are copied to the cloud too). */
folder_cloud: 'OneDrive' | 'Dropbox' | 'Google Drive' | 'iCloud' | null
backups: BackupEntry[]
Expand Down
5 changes: 3 additions & 2 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -3605,7 +3605,8 @@ survives losing it: on Windows the Known Folder API's Documents (`SHGetKnownFold
folder redirected to OneDrive is used), else `~/Documents`; `~/Documents` on macOS; on Linux `XDG_DOCUMENTS_DIR` when
set, else `~/Documents`
(`SENTIENT_DOCUMENTS_DIR` overrides it, and tests always set it). When Documents is missing or can't be written to,
`<home>/backups`. A backup holds the devices certificate (`tls/`), so it should be kept somewhere private.
`<home>/backups`, and always `<home>/backups` for a data folder other than the default `~/.sentient`
(`home_is_default` in the overview), so a test or demo home never mixes its backups with the real ones. A backup holds the devices certificate (`tls/`), so it should be kept somewhere private.
`folder_cloud` names the sync service the folder is inside, from the `OneDrive`, `OneDriveConsumer` and
`OneDriveCommercial` environment variables and the path (`OneDrive...`, `Dropbox...`, `Google Drive`, `My Drive`,
`GoogleDrive-...`, `iCloud Drive`, `Mobile Documents`, `com~apple~CloudDocs`); Settings then says the backups are
Expand Down Expand Up @@ -3656,7 +3657,7 @@ back; if even that fails, it stays in `<home>/backups/restore-old-<time>/` and t

**BackupOverview**
```json
{"folder": "...", "default_folder": "~/Documents/Sentient Backups", "home": "<home>", "folder_in_home": false, "folder_cloud": "OneDrive|Dropbox|Google Drive|iCloud|null",
{"folder": "...", "default_folder": "~/Documents/Sentient Backups", "home": "<home>", "folder_in_home": false, "home_is_default": true, "folder_cloud": "OneDrive|Dropbox|Google Drive|iCloud|null",
"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,
Expand Down
11 changes: 8 additions & 3 deletions sentient/backup/archive.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
Never inside: keychain secrets (API keys, tokens, sign-ins), ``gateway.token``, logs, the browser profile,
WhatsApp's link, downloaded voice models, scratch folders, the backups folder itself, and open incognito chats.

Backups go to ``Documents/Sentient Backups`` by default (:func:`default_folder`), outside the data folder.
Backups go to ``Documents/Sentient Backups`` by default (:func:`default_folder`), outside the data folder; any
data folder other than ``~/.sentient`` keeps them in its own ``backups`` folder instead.

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.
Expand All @@ -37,7 +38,7 @@
from pathlib import Path, PurePosixPath
from typing import Any

from sentient import __version__, paths
from sentient import __version__, paths, secrets

log = logging.getLogger(__name__)

Expand Down Expand Up @@ -92,7 +93,11 @@ def cloud_service(path: Path) -> str | None:

def default_folder(home: Path | None = None) -> Path:
"""Where backups go unless the user picked a folder: ``Documents/Sentient Backups``, outside the data folder so a
backup survives losing it. ``<home>/backups`` when Documents is missing or can't be written to."""
backup survives losing it. ``<home>/backups`` when Documents is missing or can't be written to, and always for a
data folder other than the default ``~/.sentient`` (a test or demo home, another profile), so its backups never
land next to the real ones."""
if not secrets.is_default_home(home):
return backups_root(home)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
docs = paths.documents_dir()
target = docs / BACKUP_FOLDER_NAME
try:
Expand Down
1 change: 1 addition & 0 deletions sentient/backup/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ async def overview(self) -> dict:
"default_folder": str(archive.default_folder()),
"home": str(paths.home()),
"folder_in_home": archive.is_inside(folder, paths.home()),
"home_is_default": secrets.is_default_home(),
"folder_cloud": archive.cloud_service(folder),
"backups": [b for b in backups if b["kind"] != "snapshot"],
"snapshots": [s for s in snapshots if s["kind"] == "snapshot"],
Expand Down
2 changes: 1 addition & 1 deletion sentient/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ def claude_code_tools(tools_file: str):

@app.command()
def backup(
to: Path | None = typer.Option(None, "--to", help="Folder for the backup (default: Documents/Sentient Backups)."),
to: Path | None = typer.Option(None, "--to", help="Folder for the backup (default: Documents/Sentient Backups; a data folder other than ~/.sentient uses its own backups folder)."),
):
"""Back up the data folder (database, config, workspace, skills, files) to one zip file."""
from sentient.backup import archive
Expand Down
4 changes: 2 additions & 2 deletions sentient/config/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -1262,8 +1262,8 @@ class BackupsConfig(BaseModel):
folder: str | None = Field(
None,
description="Folder for backups. Empty uses Sentient Backups in your Documents folder (or the backups folder "
"inside Sentient's data folder when Documents can't be used). A folder on another drive or a synced cloud "
"folder keeps them safe if this computer fails.",
"inside Sentient's data folder when Documents can't be used, and always for a data folder other than the "
"default ~/.sentient). 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."
Expand Down
43 changes: 40 additions & 3 deletions tests/test_backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,12 @@ def stuck(src, dst, tries=6):
assert len(kept) == 1 and (kept[0] / "files" / "mine.txt").read_text(encoding="utf-8") == "only copy"


@pytest.fixture
def default_home(monkeypatch):
"""Treat the test's data folder as the default ``~/.sentient``, the only one whose backups go to Documents."""
monkeypatch.setattr(archive.secrets, "is_default_home", lambda home=None: True)


def test_tests_never_use_the_real_folders(isolated_home, tmp_path):
"""The guard in conftest compares REAL_GUARDED before and after every test; here the overrides themselves."""
assert paths.home() == isolated_home and paths.documents_dir() == tmp_path / "documents"
Expand All @@ -353,7 +359,7 @@ def test_tests_never_use_the_real_folders(isolated_home, tmp_path):
assert not str(archive.default_folder()).startswith(str(real.parent))


async def test_backups_go_to_documents_by_default(config, isolated_home, tmp_path):
async def test_backups_go_to_documents_by_default(config, isolated_home, tmp_path, default_home):
docs = tmp_path / "documents"
docs.mkdir()
app = await _app(config)
Expand All @@ -363,21 +369,50 @@ async def test_backups_go_to_documents_by_default(config, isolated_home, tmp_pat
overview = await app.backups.overview()
assert overview["folder"] == overview["default_folder"] == str(docs / "Sentient Backups")
assert overview["home"] == str(isolated_home) and overview["folder_in_home"] is False
assert overview["home_is_default"] is True
assert [b["name"] for b in overview["backups"]] == [made["name"]]
# snapshots, a staged restore and the restore result stay in the data folder
assert (await app.backups.snapshot("before a test"))["path"].startswith(str(isolated_home / "backups"))
finally:
await app.stop()


def test_a_documents_folder_that_cant_be_written_falls_back(isolated_home, tmp_path, monkeypatch):
def test_a_documents_folder_that_cant_be_written_falls_back(isolated_home, tmp_path, monkeypatch, default_home):
docs = tmp_path / "documents"
docs.mkdir()
assert archive.default_folder() == docs / "Sentient Backups"
monkeypatch.setattr(archive.os, "access", lambda p, mode: False)
assert archive.default_folder() == isolated_home / "backups"


async def test_another_data_folder_keeps_its_backups_inside_itself(config, isolated_home, tmp_path):
"""A test or demo home (any SENTIENT_HOME but ~/.sentient) must not default to the real Documents backups (#440)."""
docs = tmp_path / "documents"
docs.mkdir()
assert not secrets.is_default_home()
assert archive.default_folder() == isolated_home / "backups"
app = await _app(config)
try:
made = await app.backups.backup_now()
assert Path(made["path"]).parent == isolated_home / "backups"
overview = await app.backups.overview()
assert overview["folder"] == overview["default_folder"] == str(isolated_home / "backups")
assert overview["folder_in_home"] is True and overview["home_is_default"] is False
assert not (docs / "Sentient Backups").exists()
finally:
await app.stop()


def test_only_the_default_data_folder_backs_up_to_documents(isolated_home, tmp_path, monkeypatch):
docs = tmp_path / "documents"
docs.mkdir()
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path / "me"))
monkeypatch.setenv("SENTIENT_HOME", str(tmp_path / "me" / ".sentient"))
assert archive.default_folder() == docs / "Sentient Backups"
monkeypatch.setenv("SENTIENT_HOME", str(tmp_path / "me" / "demo"))
assert archive.default_folder() == tmp_path / "me" / "demo" / "backups"


def test_where_documents_is_on_each_system(tmp_path, monkeypatch):
# only resolves paths: nothing here creates or writes a folder
monkeypatch.delenv("SENTIENT_DOCUMENTS_DIR")
Expand Down Expand Up @@ -463,7 +498,9 @@ def test_cloud_synced_folders_are_named(tmp_path, monkeypatch):
assert archive.cloud_service(synced / "Documents" / "Sentient Backups") == "OneDrive"


async def test_the_overview_says_when_backups_sync_to_the_cloud(config, isolated_home, tmp_path, monkeypatch):
async def test_the_overview_says_when_backups_sync_to_the_cloud(
config, isolated_home, tmp_path, monkeypatch, default_home
):
docs = tmp_path / "OneDrive" / "Documents"
docs.mkdir(parents=True)
monkeypatch.setenv("SENTIENT_DOCUMENTS_DIR", str(docs))
Expand Down
Loading