personal memory agent
1# SPDX-License-Identifier: AGPL-3.0-only
2# Copyright (c) 2026 sol pbc
3
4from __future__ import annotations
5
6import hashlib
7import json
8import os
9import shlex
10import shutil
11import socket
12from pathlib import Path
13from typing import Any
14
15import pytest
16
17from solstone.think.backup import restore, rotation, teardown
18from solstone.think.backup.destination import Destination, validate_destination
19from solstone.think.backup.engine import BACKUP_EXCLUDES
20from solstone.think.backup.install import ensure_restic
21from solstone.think.backup.readiness import (
22 RESTIC_SCHEMA_VERSION,
23 RESTIC_TOOL,
24 RESTIC_VERSION,
25 _binary_path,
26 _platform_info,
27 _sentinel_path,
28 _tool_dir,
29)
30from solstone.think.backup.repo import init_repository
31from solstone.think.backup.runner import run_restic
32from solstone.think.backup.state import BACKUP_DEFAULTS, get_destination
33from solstone.think.indexer.journal import ScanReport
34from tests.helpers.journal_config import seed_journal_config
35
36RESTIC_BIN = shutil.which("restic")
37
38
39def _config_path(journal: Path) -> Path:
40 return journal / "config" / "journal.json"
41
42
43def _write_json(path: Path, payload: dict[str, Any]) -> None:
44 path.parent.mkdir(parents=True, exist_ok=True)
45 path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
46
47
48def _read_json(path: Path) -> dict[str, Any]:
49 return json.loads(path.read_text(encoding="utf-8"))
50
51
52def _destination(repo: Path) -> Destination:
53 return Destination(
54 repository=f"local:{repo}",
55 backend="s3",
56 credentials={
57 "access_key_id": "test-access-key",
58 "secret_access_key": "test-secret-key",
59 },
60 )
61
62
63def _seed_backup_config(
64 journal: Path,
65 *,
66 destination: Destination,
67 daily_key: str,
68 recovery_key: str,
69) -> None:
70 seed_journal_config(
71 {
72 "backup": {
73 "enabled": True,
74 "destination": {
75 "repository": destination.repository,
76 "backend": destination.backend,
77 "credentials": destination.credentials,
78 },
79 "daily_key": daily_key,
80 "recovery_key": recovery_key,
81 "confirmed_recovery_key": True,
82 }
83 },
84 journal,
85 )
86
87
88def _install_restic_wrapper(restic_bin: str) -> Path:
89 os_name, arch = _platform_info()
90 tool_dir = _tool_dir(os_name)
91 tool_dir.mkdir(parents=True, exist_ok=True)
92 binary_path = _binary_path(tool_dir)
93 binary_path.write_text(
94 "#!/usr/bin/env sh\n"
95 'if [ "$1" = "version" ]; then\n'
96 f" echo 'restic {RESTIC_VERSION} test wrapper'\n"
97 " exit 0\n"
98 "fi\n"
99 f'exec {shlex.quote(restic_bin)} "$@"\n',
100 encoding="utf-8",
101 )
102 binary_path.chmod(0o755)
103 digest = hashlib.sha256(binary_path.read_bytes()).hexdigest()
104 _write_json(
105 _sentinel_path(tool_dir),
106 {
107 "schema_version": RESTIC_SCHEMA_VERSION,
108 "tool": RESTIC_TOOL,
109 "version": RESTIC_VERSION,
110 "sha256": digest,
111 "platform": {"os": os_name, "arch": arch},
112 "binary_path": str(binary_path),
113 },
114 )
115 return binary_path
116
117
118def _backup_journal(
119 *,
120 journal: Path,
121 destination: Destination,
122 daily_key: str,
123 recovery_key: str,
124 restic_path: Path,
125) -> None:
126 init_repository(
127 destination,
128 daily_key=daily_key,
129 recovery_key=recovery_key,
130 restic_path=restic_path,
131 timeout=30,
132 )
133 args = ["backup", str(journal)]
134 for pattern in BACKUP_EXCLUDES:
135 args.extend(["--exclude", pattern])
136 result = run_restic(
137 args,
138 repository=destination.repository,
139 password=daily_key,
140 restic_path=restic_path,
141 timeout=60,
142 )
143 assert result.returncode == 0, result.stderr
144
145
146@pytest.mark.skipif(RESTIC_BIN is None, reason="restic is not installed")
147@pytest.mark.integration
148@pytest.mark.timeout(120)
149def test_backup_restore_rotation_teardown_real_local_round_trip(
150 tmp_path: Path,
151 monkeypatch: pytest.MonkeyPatch,
152) -> None:
153 home = tmp_path / "home"
154 home.mkdir()
155 monkeypatch.setenv("HOME", str(home))
156 restic_path = _install_restic_wrapper(RESTIC_BIN or "")
157
158 repo = tmp_path / "repo"
159 source_journal = tmp_path / "source-journal"
160 restored_journal = tmp_path / "restored-journal"
161 source_journal.mkdir()
162 restored_journal.mkdir()
163 destination = _destination(repo)
164 daily_key = "daily-password"
165 recovery_key = ("0" * 32) + ("1" * 32)
166 entered_recovery_key = ("O" * 32) + ("I" * 32)
167
168 _seed_backup_config(
169 source_journal,
170 destination=destination,
171 daily_key=daily_key,
172 recovery_key=recovery_key,
173 )
174 (source_journal / "chronicle" / "20260612").mkdir(parents=True)
175 (source_journal / "chronicle" / "20260612" / "note.txt").write_text(
176 "journal content\n",
177 encoding="utf-8",
178 )
179 (source_journal / "health" / "pruning-runs").mkdir(parents=True)
180 (source_journal / "health" / "retention.log").write_text(
181 "retention audit\n",
182 encoding="utf-8",
183 )
184 (source_journal / "health" / "pruning-runs" / "20260701.jsonl").write_text(
185 "pruning audit\n",
186 encoding="utf-8",
187 )
188 (source_journal / "health" / "supervisor.pid").write_text(
189 "12345\n",
190 encoding="utf-8",
191 )
192 (source_journal / "health" / "supervisor.ready").write_text(
193 "ready\n",
194 encoding="utf-8",
195 )
196 (source_journal / "health" / "convey.port").write_text(
197 "5015\n",
198 encoding="utf-8",
199 )
200 (source_journal / "health" / ".steward.lock").write_text(
201 "locked\n",
202 encoding="utf-8",
203 )
204 (source_journal / "health" / ".steward_inflight.tmp").write_text(
205 "partial\n",
206 encoding="utf-8",
207 )
208 (source_journal / "chronicle" / "20260701" / "health" / "talent-provenance").mkdir(
209 parents=True
210 )
211 (
212 source_journal
213 / "chronicle"
214 / "20260701"
215 / "health"
216 / "talent-provenance"
217 / "run.json"
218 ).write_text('{"run":"audit"}\n', encoding="utf-8")
219 callosum_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
220 cwd = os.getcwd()
221 try:
222 os.chdir(source_journal / "health")
223 callosum_socket.bind("callosum.sock")
224 finally:
225 os.chdir(cwd)
226
227 monkeypatch.setenv("SOLSTONE_JOURNAL", str(source_journal))
228 assert ensure_restic() == restic_path
229 try:
230 _backup_journal(
231 journal=source_journal,
232 destination=destination,
233 daily_key=daily_key,
234 recovery_key=recovery_key,
235 restic_path=restic_path,
236 )
237 finally:
238 callosum_socket.close()
239
240 scan_calls: list[tuple[str, dict[str, Any]]] = []
241
242 def fake_scan_journal(journal: str, **kwargs: Any) -> ScanReport:
243 scan_calls.append((journal, kwargs))
244 return ScanReport(changed=True, edge_rows_inserted=0)
245
246 monkeypatch.setattr(restore, "scan_journal", fake_scan_journal)
247 monkeypatch.setenv("SOLSTONE_JOURNAL", str(restored_journal))
248
249 restore_result = restore.restore_journal(destination, entered_recovery_key)
250
251 assert restore_result.status == "ok"
252 assert restore_result.integrity_ok is True
253 assert restore_result.resumable is True
254 assert isinstance(restore_result.bytes_restored, int)
255 assert restore_result.bytes_restored > 0
256 assert (restored_journal / "chronicle" / "20260612" / "note.txt").read_text(
257 encoding="utf-8"
258 ) == "journal content\n"
259 assert (restored_journal / "health" / "retention.log").read_text(
260 encoding="utf-8"
261 ) == "retention audit\n"
262 assert (restored_journal / "health" / "pruning-runs" / "20260701.jsonl").read_text(
263 encoding="utf-8"
264 ) == "pruning audit\n"
265 assert (
266 restored_journal
267 / "chronicle"
268 / "20260701"
269 / "health"
270 / "talent-provenance"
271 / "run.json"
272 ).read_text(encoding="utf-8") == '{"run":"audit"}\n'
273 assert not (restored_journal / "health" / "callosum.sock").exists()
274 assert not (restored_journal / "health" / "supervisor.pid").exists()
275 assert not (restored_journal / "health" / "supervisor.ready").exists()
276 assert not (restored_journal / "health" / "convey.port").exists()
277 assert not (restored_journal / "health" / ".steward.lock").exists()
278 assert not (restored_journal / "health" / ".steward_inflight.tmp").exists()
279 restored_config = _read_json(_config_path(restored_journal))
280 assert restored_config["backup"]["daily_key"] == daily_key
281 assert restored_config["backup"]["destination"] == {
282 "repository": destination.repository,
283 "backend": destination.backend,
284 "credentials": destination.credentials,
285 }
286 assert restored_config["backup"]["recovery_key"] == recovery_key
287 assert restored_config["backup"]["confirmed_recovery_key"] is True
288 assert scan_calls == [(str(restored_journal), {"full": True})]
289
290 rotation_result = rotation.rotate_recovery_key()
291
292 assert rotation_result.status == "ok"
293 assert rotation_result.recovery_key is not None
294 assert rotation_result.recovery_key != recovery_key
295 active_config = _read_json(_config_path(restored_journal))
296 assert active_config["backup"]["daily_key"] == daily_key
297 assert active_config["backup"]["recovery_key"] == rotation_result.recovery_key
298 assert active_config["backup"]["confirmed_recovery_key"] is False
299
300 active_destination = get_destination()
301 assert active_destination == destination
302 new_status = validate_destination(
303 active_destination,
304 rotation_result.recovery_key,
305 restic_path=restic_path,
306 timeout=30,
307 )
308 old_status = validate_destination(
309 active_destination,
310 recovery_key,
311 restic_path=restic_path,
312 timeout=30,
313 )
314 assert new_status.reason_code == "repo_exists"
315 assert old_status.reason_code == "auth_failed"
316
317 teardown_result = teardown.teardown_backup()
318
319 assert teardown_result.status == "ok"
320 snapshots = run_restic(
321 ["snapshots"],
322 repository=destination.repository,
323 password=daily_key,
324 restic_path=restic_path,
325 json=True,
326 timeout=30,
327 )
328 assert snapshots.returncode == 0, snapshots.stderr
329 assert snapshots.json == []
330 assert _read_json(_config_path(restored_journal))["backup"] == BACKUP_DEFAULTS