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
8from pathlib import Path
9from unittest.mock import MagicMock, patch
10
11import pytest
12import requests
13
14import solstone.think.utils as think_utils
15from solstone.observe.utils import compute_bytes_sha256
16
17
18def _setup_journal(tmp_path, *, include_stream_json: bool = False):
19 first_dir = tmp_path / "chronicle" / "20260413" / "laptop" / "143022_300"
20 second_dir = tmp_path / "chronicle" / "20260413" / "laptop" / "150000_600"
21 first_dir.mkdir(parents=True)
22 second_dir.mkdir(parents=True)
23
24 (first_dir / "audio.flac").write_bytes(b"audio-data-one")
25 (first_dir / "transcript.jsonl").write_bytes(b"transcript-one")
26 (second_dir / "audio.flac").write_bytes(b"audio-data-two")
27 (second_dir / "transcript.jsonl").write_bytes(b"transcript-two")
28
29 if include_stream_json:
30 (first_dir / "stream.json").write_bytes(b'{"name": "laptop"}')
31 (second_dir / "stream.json").write_bytes(b'{"name": "laptop"}')
32
33 return tmp_path
34
35
36def _set_journal_override(monkeypatch, journal_path):
37 monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_path))
38 think_utils._journal_path_cache = None
39
40
41def _make_session(
42 *, manifest_data=None, get_status=200, post_status=200, post_json=None
43):
44 mock = MagicMock(spec=requests.Session)
45 mock.headers = {}
46
47 get_response = MagicMock()
48 get_response.status_code = get_status
49 get_response.json.return_value = manifest_data if manifest_data is not None else {}
50 get_response.text = "GET error"
51 mock.get.return_value = get_response
52
53 post_response = MagicMock()
54 post_response.status_code = post_status
55 post_response.json.return_value = (
56 post_json
57 if post_json is not None
58 else {
59 "segments_received": 1,
60 "segments_skipped": 0,
61 "segments_deconflicted": 0,
62 "errors": [],
63 }
64 )
65 post_response.text = "POST error"
66 mock.post.return_value = post_response
67
68 return mock
69
70
71def _setup_entities(tmp_path):
72 """Create test entities in journal fixture."""
73 entities_dir = tmp_path / "entities"
74
75 alice_dir = entities_dir / "alice_johnson"
76 alice_dir.mkdir(parents=True)
77 alice = {
78 "id": "alice_johnson",
79 "name": "Alice Johnson",
80 "type": "Person",
81 "created_at": 1000,
82 }
83 (alice_dir / "entity.json").write_text(json.dumps(alice), encoding="utf-8")
84
85 bob_dir = entities_dir / "bob_smith"
86 bob_dir.mkdir(parents=True)
87 bob = {"id": "bob_smith", "name": "Bob Smith", "type": "Person", "created_at": 2000}
88 (bob_dir / "entity.json").write_text(json.dumps(bob), encoding="utf-8")
89
90 blocked_dir = entities_dir / "blocked_user"
91 blocked_dir.mkdir(parents=True)
92 blocked = {
93 "id": "blocked_user",
94 "name": "Blocked",
95 "type": "Person",
96 "blocked": True,
97 "created_at": 3000,
98 }
99 (blocked_dir / "entity.json").write_text(json.dumps(blocked), encoding="utf-8")
100
101 return {"alice_johnson": alice, "bob_smith": bob, "blocked_user": blocked}
102
103
104def _entity_hash(entity: dict) -> str:
105 return hashlib.sha256(
106 json.dumps(entity, sort_keys=True, ensure_ascii=False).encode()
107 ).hexdigest()
108
109
110def _setup_facets(tmp_path):
111 """Create test facets in journal fixture."""
112 facets_dir = tmp_path / "facets"
113
114 work_dir = facets_dir / "work"
115 work_dir.mkdir(parents=True)
116 (work_dir / "facet.json").write_text('{"title": "Work"}', encoding="utf-8")
117
118 ent_dir = work_dir / "entities" / "alice"
119 ent_dir.mkdir(parents=True)
120 (ent_dir / "entity.json").write_text('{"id": "alice"}', encoding="utf-8")
121 (ent_dir / "observations.jsonl").write_text('{"text": "obs1"}\n', encoding="utf-8")
122
123 det_dir = work_dir / "entities"
124 (det_dir / "20260413.jsonl").write_text('{"entity": "test"}\n', encoding="utf-8")
125
126 personal_dir = facets_dir / "personal"
127 personal_dir.mkdir(parents=True)
128 (personal_dir / "facet.json").write_text('{"title": "Personal"}', encoding="utf-8")
129
130 news_dir = personal_dir / "news"
131 news_dir.mkdir(parents=True)
132 (news_dir / "20260413.md").write_text("# News\nHello world\n", encoding="utf-8")
133
134 return facets_dir
135
136
137def _facet_file_hash(path: Path) -> str:
138 return hashlib.sha256(path.read_bytes()).hexdigest()
139
140
141def _setup_imports(tmp_path):
142 """Create test import metadata dirs in journal fixture."""
143 imports_dir = tmp_path / "imports"
144 imports_dir.mkdir(parents=True, exist_ok=True)
145
146 dir1 = imports_dir / "20260101_090000"
147 dir1.mkdir()
148 import_json_1 = {"original_filename": "cal.zip", "file_size": 100}
149 imported_json_1 = {
150 "processed_timestamp": "20260101_090000",
151 "total_files_created": 1,
152 }
153 manifest_1 = [{"id": "event-0", "title": "Test Event"}]
154 (dir1 / "import.json").write_text(json.dumps(import_json_1), encoding="utf-8")
155 (dir1 / "imported.json").write_text(json.dumps(imported_json_1), encoding="utf-8")
156 (dir1 / "content_manifest.jsonl").write_text(
157 json.dumps(manifest_1[0]) + "\n", encoding="utf-8"
158 )
159
160 dir2 = imports_dir / "20260102_100000"
161 dir2.mkdir()
162 import_json_2 = {"original_filename": "chat.zip", "file_size": 200}
163 imported_json_2 = {
164 "processed_timestamp": "20260102_100000",
165 "total_files_created": 2,
166 }
167 manifest_2 = [{"id": "conv-0", "title": "Test Convo"}]
168 (dir2 / "import.json").write_text(json.dumps(import_json_2), encoding="utf-8")
169 (dir2 / "imported.json").write_text(json.dumps(imported_json_2), encoding="utf-8")
170 (dir2 / "content_manifest.jsonl").write_text(
171 json.dumps(manifest_2[0]) + "\n", encoding="utf-8"
172 )
173
174 (imports_dir / "plaud.json").write_text('{"last_sync": 123}', encoding="utf-8")
175
176 source_dir = imports_dir / "abcd1234"
177 source_dir.mkdir()
178 (source_dir / "segments").mkdir()
179
180 return {
181 "20260101_090000": {
182 "import_json": import_json_1,
183 "imported_json": imported_json_1,
184 "content_manifest": manifest_1,
185 },
186 "20260102_100000": {
187 "import_json": import_json_2,
188 "imported_json": imported_json_2,
189 "content_manifest": manifest_2,
190 },
191 }
192
193
194def _import_hash(import_data):
195 """Compute hash matching export_imports algorithm."""
196 hash_input = json.dumps(
197 {
198 "import_json": import_data["import_json"],
199 "imported_json": import_data["imported_json"],
200 "content_manifest": import_data["content_manifest"],
201 },
202 sort_keys=True,
203 ensure_ascii=False,
204 ).encode()
205 return hashlib.sha256(hash_input).hexdigest()
206
207
208def _setup_config(tmp_path):
209 """Create test config in journal fixture."""
210 config_dir = tmp_path / "config"
211 config_dir.mkdir(parents=True, exist_ok=True)
212 config = {
213 "identity": {"name": "Test", "preferred": "Tester", "timezone": "UTC"},
214 "convey": {
215 "allow_network_access": False,
216 "password_hash": "secret_hash",
217 "secret": "secret_val",
218 },
219 "setup": {"completed_at": 12345},
220 "env": {"KEY": "val"},
221 "retention": {"days": 30},
222 }
223 (config_dir / "journal.json").write_text(json.dumps(config), encoding="utf-8")
224 return config
225
226
227class TestExportSegments:
228 def test_manifest_query_and_delta(self, tmp_path, monkeypatch):
229 from solstone.observe.export import export_segments
230
231 journal = _setup_journal(tmp_path)
232 _set_journal_override(monkeypatch, journal)
233
234 manifest_data = {
235 "20260413": {
236 "laptop/143022_300": {
237 "files": [
238 {
239 "name": "audio.flac",
240 "sha256": compute_bytes_sha256(b"audio-data-one"),
241 "size": len(b"audio-data-one"),
242 },
243 {
244 "name": "transcript.jsonl",
245 "sha256": compute_bytes_sha256(b"transcript-one"),
246 "size": len(b"transcript-one"),
247 },
248 ]
249 }
250 }
251 }
252 mock_session = _make_session(manifest_data=manifest_data)
253
254 with patch(
255 "solstone.observe.export.requests.Session", return_value=mock_session
256 ):
257 export_segments(
258 "https://example.com", "test-key", ["20260413"], dry_run=False
259 )
260
261 assert mock_session.post.call_count == 1
262 metadata = json.loads(mock_session.post.call_args.kwargs["data"]["metadata"])
263 assert metadata["segments"][0]["segment_key"] == "150000_600"
264
265 def test_dry_run_output(self, tmp_path, monkeypatch, capsys):
266 from solstone.observe.export import export_segments
267
268 journal = _setup_journal(tmp_path)
269 _set_journal_override(monkeypatch, journal)
270
271 mock_session = _make_session(manifest_data={})
272
273 with patch(
274 "solstone.observe.export.requests.Session", return_value=mock_session
275 ):
276 export_segments(
277 "https://example.com", "test-key", ["20260413"], dry_run=True
278 )
279
280 assert mock_session.post.call_count == 0
281 output = capsys.readouterr().out
282 assert "20260413: 2 segment(s)" in output
283 assert "Dry run: would send 2, skip 0" in output
284
285 def test_dry_run_skipped_not_double_counted(self, tmp_path, monkeypatch, capsys):
286 from solstone.observe.export import export_segments
287
288 journal = _setup_journal(tmp_path)
289 _set_journal_override(monkeypatch, journal)
290
291 manifest_data = {
292 "20260413": {
293 "laptop/143022_300": {
294 "files": [
295 {
296 "name": "audio.flac",
297 "sha256": compute_bytes_sha256(b"audio-data-one"),
298 "size": len(b"audio-data-one"),
299 },
300 {
301 "name": "transcript.jsonl",
302 "sha256": compute_bytes_sha256(b"transcript-one"),
303 "size": len(b"transcript-one"),
304 },
305 ]
306 }
307 }
308 }
309 mock_session = _make_session(manifest_data=manifest_data)
310
311 with patch(
312 "solstone.observe.export.requests.Session", return_value=mock_session
313 ):
314 export_segments(
315 "https://example.com", "test-key", ["20260413"], dry_run=True
316 )
317
318 assert mock_session.post.call_count == 0
319 output = capsys.readouterr().out
320 assert "20260413: 1 segment(s)" in output
321 assert "Dry run: would send 1, skip 1" in output
322
323 def test_retry_on_5xx(self, tmp_path, monkeypatch):
324 from solstone.observe.export import export_segments
325
326 journal = _setup_journal(tmp_path)
327 _set_journal_override(monkeypatch, journal)
328
329 second_dir = journal / "chronicle" / "20260413" / "laptop" / "150000_600"
330 for file_path in second_dir.iterdir():
331 file_path.unlink()
332 second_dir.rmdir()
333
334 mock_session = _make_session(manifest_data={})
335 first = MagicMock(status_code=500, text="server error")
336 second = MagicMock(status_code=500, text="server error")
337 success = MagicMock(status_code=200, text="ok")
338 success.json.return_value = {
339 "segments_received": 1,
340 "segments_skipped": 0,
341 "segments_deconflicted": 0,
342 "errors": [],
343 }
344 mock_session.post.side_effect = [first, second, success]
345
346 with (
347 patch(
348 "solstone.observe.export.requests.Session", return_value=mock_session
349 ),
350 patch("solstone.observe.export.time.sleep") as mock_sleep,
351 ):
352 export_segments(
353 "https://example.com", "test-key", ["20260413"], dry_run=False
354 )
355
356 assert mock_session.post.call_count == 3
357 assert mock_sleep.called
358
359 def test_auth_error_401(self):
360 from solstone.observe.export import _query_manifest
361
362 mock_session = _make_session(get_status=401)
363
364 with pytest.raises(ValueError, match="Authentication failed"):
365 _query_manifest(mock_session, "https://example.com", "test-key")
366
367 def test_auth_error_403(self):
368 from solstone.observe.export import _query_manifest
369
370 mock_session = _make_session(get_status=403)
371
372 with pytest.raises(ValueError, match="journal source revoked"):
373 _query_manifest(mock_session, "https://example.com", "test-key")
374
375 def test_connection_error(self, tmp_path, monkeypatch, capsys):
376 from solstone.observe.export import export_segments
377
378 journal = _setup_journal(tmp_path)
379 _set_journal_override(monkeypatch, journal)
380
381 mock_session = _make_session(manifest_data={})
382 mock_session.get.side_effect = requests.ConnectionError
383
384 with patch(
385 "solstone.observe.export.requests.Session", return_value=mock_session
386 ):
387 export_segments(
388 "https://example.com", "test-key", ["20260413"], dry_run=False
389 )
390
391 assert mock_session.post.call_count == 0
392 assert "Connection failed" in capsys.readouterr().out
393
394 def test_idempotent(self, tmp_path, monkeypatch, capsys):
395 from solstone.observe.export import export_segments
396
397 journal = _setup_journal(tmp_path)
398 _set_journal_override(monkeypatch, journal)
399
400 manifest_data = {
401 "20260413": {
402 "laptop/143022_300": {
403 "files": [
404 {
405 "name": "audio.flac",
406 "sha256": compute_bytes_sha256(b"audio-data-one"),
407 "size": len(b"audio-data-one"),
408 },
409 {
410 "name": "transcript.jsonl",
411 "sha256": compute_bytes_sha256(b"transcript-one"),
412 "size": len(b"transcript-one"),
413 },
414 ]
415 },
416 "laptop/150000_600": {
417 "files": [
418 {
419 "name": "audio.flac",
420 "sha256": compute_bytes_sha256(b"audio-data-two"),
421 "size": len(b"audio-data-two"),
422 },
423 {
424 "name": "transcript.jsonl",
425 "sha256": compute_bytes_sha256(b"transcript-two"),
426 "size": len(b"transcript-two"),
427 },
428 ]
429 },
430 }
431 }
432 mock_session = _make_session(manifest_data=manifest_data)
433
434 with patch(
435 "solstone.observe.export.requests.Session", return_value=mock_session
436 ):
437 export_segments(
438 "https://example.com", "test-key", ["20260413"], dry_run=False
439 )
440
441 assert mock_session.post.call_count == 0
442 assert "up to date" in capsys.readouterr().out
443
444 def test_upload_error_isolation(self, tmp_path, monkeypatch, capsys):
445 from solstone.observe.export import export_segments
446
447 journal = _setup_journal(tmp_path)
448 _set_journal_override(monkeypatch, journal)
449
450 mock_session = _make_session(manifest_data={})
451 first = MagicMock(status_code=400, text="bad request")
452 second = MagicMock(status_code=200, text="ok")
453 second.json.return_value = {
454 "segments_received": 1,
455 "segments_skipped": 0,
456 "segments_deconflicted": 0,
457 "errors": [],
458 }
459 mock_session.post.side_effect = [first, second]
460
461 with patch(
462 "solstone.observe.export.requests.Session", return_value=mock_session
463 ):
464 export_segments(
465 "https://example.com", "test-key", ["20260413"], dry_run=False
466 )
467
468 assert mock_session.post.call_count == 2
469 output = capsys.readouterr().out
470 assert "1 sent" in output
471 assert "1 failed" in output
472
473 def test_stream_json_excluded(self, tmp_path, monkeypatch):
474 from solstone.observe.export import export_segments
475
476 journal = _setup_journal(tmp_path, include_stream_json=True)
477 _set_journal_override(monkeypatch, journal)
478
479 mock_session = _make_session(manifest_data={})
480
481 with patch(
482 "solstone.observe.export.requests.Session", return_value=mock_session
483 ):
484 export_segments(
485 "https://example.com", "test-key", ["20260413"], dry_run=False
486 )
487
488 for call in mock_session.post.call_args_list:
489 post_kwargs = call.kwargs
490 metadata = json.loads(post_kwargs["data"]["metadata"])
491 assert "stream.json" not in metadata["segments"][0]["files"]
492 uploaded_names = [entry[1][0] for entry in post_kwargs["files"]]
493 assert "stream.json" not in uploaded_names
494
495
496class TestExportEntities:
497 def test_manifest_delta(self, tmp_path, monkeypatch):
498 from solstone.observe.export import export_entities
499
500 entities = _setup_entities(tmp_path)
501 _set_journal_override(monkeypatch, tmp_path)
502
503 manifest_data = {
504 "received": {
505 "alice_johnson": _entity_hash(entities["alice_johnson"]),
506 }
507 }
508 post_json = {
509 "created": 1,
510 "auto_merged": 0,
511 "staged": 0,
512 "skipped": 0,
513 "errors": [],
514 }
515 mock_session = _make_session(manifest_data=manifest_data, post_json=post_json)
516
517 with patch(
518 "solstone.observe.export.requests.Session", return_value=mock_session
519 ):
520 export_entities("https://example.com", "test-key", dry_run=False)
521
522 assert mock_session.post.call_count == 1
523 posted_data = mock_session.post.call_args.kwargs.get(
524 "json"
525 ) or mock_session.post.call_args[1].get("json")
526 posted_entities = posted_data["entities"]
527 posted_ids = [e["id"] for e in posted_entities]
528 assert "bob_smith" in posted_ids
529 assert "alice_johnson" not in posted_ids
530 assert "blocked_user" not in posted_ids
531
532 def test_dry_run(self, tmp_path, monkeypatch, capsys):
533 from solstone.observe.export import export_entities
534
535 entities = _setup_entities(tmp_path)
536 _set_journal_override(monkeypatch, tmp_path)
537
538 manifest_data = {
539 "received": {
540 "alice_johnson": _entity_hash(entities["alice_johnson"]),
541 }
542 }
543 mock_session = _make_session(manifest_data=manifest_data)
544
545 with patch(
546 "solstone.observe.export.requests.Session", return_value=mock_session
547 ):
548 export_entities("https://example.com", "test-key", dry_run=True)
549
550 assert mock_session.post.call_count == 0
551 output = capsys.readouterr().out
552 assert "1 new" in output
553 assert "1 unchanged" in output
554
555 def test_idempotent(self, tmp_path, monkeypatch, capsys):
556 from solstone.observe.export import export_entities
557
558 entities = _setup_entities(tmp_path)
559 _set_journal_override(monkeypatch, tmp_path)
560
561 manifest_data = {
562 "received": {
563 "alice_johnson": _entity_hash(entities["alice_johnson"]),
564 "bob_smith": _entity_hash(entities["bob_smith"]),
565 }
566 }
567 mock_session = _make_session(manifest_data=manifest_data)
568
569 with patch(
570 "solstone.observe.export.requests.Session", return_value=mock_session
571 ):
572 export_entities("https://example.com", "test-key", dry_run=False)
573
574 assert mock_session.post.call_count == 0
575 output = capsys.readouterr().out
576 assert "up to date" in output
577
578 def test_changed_entity(self, tmp_path, monkeypatch):
579 from solstone.observe.export import export_entities
580
581 entities = _setup_entities(tmp_path)
582 _set_journal_override(monkeypatch, tmp_path)
583
584 manifest_data = {
585 "received": {
586 "alice_johnson": "stale_hash_that_does_not_match",
587 "bob_smith": _entity_hash(entities["bob_smith"]),
588 }
589 }
590 post_json = {
591 "created": 0,
592 "auto_merged": 1,
593 "staged": 0,
594 "skipped": 0,
595 "errors": [],
596 }
597 mock_session = _make_session(manifest_data=manifest_data, post_json=post_json)
598
599 with patch(
600 "solstone.observe.export.requests.Session", return_value=mock_session
601 ):
602 export_entities("https://example.com", "test-key", dry_run=False)
603
604 assert mock_session.post.call_count == 1
605 posted_data = mock_session.post.call_args.kwargs.get(
606 "json"
607 ) or mock_session.post.call_args[1].get("json")
608 posted_ids = [e["id"] for e in posted_data["entities"]]
609 assert "alice_johnson" in posted_ids
610 assert "bob_smith" not in posted_ids
611
612 def test_auth_error_401(self, tmp_path, monkeypatch, capsys):
613 from solstone.observe.export import export_entities
614
615 _setup_entities(tmp_path)
616 _set_journal_override(monkeypatch, tmp_path)
617
618 mock_session = _make_session(get_status=401)
619
620 with patch(
621 "solstone.observe.export.requests.Session", return_value=mock_session
622 ):
623 export_entities("https://example.com", "test-key", dry_run=False)
624
625 assert mock_session.post.call_count == 0
626 assert "Authentication failed" in capsys.readouterr().out
627
628 def test_connection_error(self, tmp_path, monkeypatch, capsys):
629 from solstone.observe.export import export_entities
630
631 _setup_entities(tmp_path)
632 _set_journal_override(monkeypatch, tmp_path)
633
634 mock_session = _make_session()
635 mock_session.get.side_effect = requests.ConnectionError
636
637 with patch(
638 "solstone.observe.export.requests.Session", return_value=mock_session
639 ):
640 export_entities("https://example.com", "test-key", dry_run=False)
641
642 assert mock_session.post.call_count == 0
643 assert "Connection failed" in capsys.readouterr().out
644
645 def test_empty_manifest(self, tmp_path, monkeypatch):
646 from solstone.observe.export import export_entities
647
648 _setup_entities(tmp_path)
649 _set_journal_override(monkeypatch, tmp_path)
650
651 post_json = {
652 "created": 2,
653 "auto_merged": 0,
654 "staged": 0,
655 "skipped": 0,
656 "errors": [],
657 }
658 mock_session = _make_session(manifest_data={}, post_json=post_json)
659
660 with patch(
661 "solstone.observe.export.requests.Session", return_value=mock_session
662 ):
663 export_entities("https://example.com", "test-key", dry_run=False)
664
665 assert mock_session.post.call_count == 1
666 posted_data = mock_session.post.call_args.kwargs.get(
667 "json"
668 ) or mock_session.post.call_args[1].get("json")
669 posted_ids = [e["id"] for e in posted_data["entities"]]
670 assert "alice_johnson" in posted_ids
671 assert "bob_smith" in posted_ids
672 assert "blocked_user" not in posted_ids
673
674 def test_response_errors_reported(self, tmp_path, monkeypatch, capsys):
675 from solstone.observe.export import export_entities
676
677 _setup_entities(tmp_path)
678 _set_journal_override(monkeypatch, tmp_path)
679
680 post_json = {
681 "created": 1,
682 "auto_merged": 0,
683 "staged": 0,
684 "skipped": 0,
685 "errors": ["Entity bob_smith: invalid type"],
686 }
687 mock_session = _make_session(manifest_data={}, post_json=post_json)
688
689 with patch(
690 "solstone.observe.export.requests.Session", return_value=mock_session
691 ):
692 export_entities("https://example.com", "test-key", dry_run=False)
693
694 output = capsys.readouterr().out
695 assert "Entity bob_smith: invalid type" in output
696 assert "1 error" in output
697
698
699class TestExportFacets:
700 def test_full_export(self, tmp_path, monkeypatch):
701 from solstone.observe.export import export_facets
702
703 _setup_facets(tmp_path)
704 _set_journal_override(monkeypatch, tmp_path)
705
706 post_json = {"created": 1, "merged": 0, "skipped": 0, "staged": 0, "errors": []}
707 mock_session = _make_session(
708 manifest_data={"received": {}}, post_json=post_json
709 )
710
711 with patch(
712 "solstone.observe.export.requests.Session", return_value=mock_session
713 ):
714 export_facets("https://example.com", "test-key", dry_run=False)
715
716 assert mock_session.post.call_count == 2
717
718 calls_by_facet = {}
719 for call in mock_session.post.call_args_list:
720 metadata = json.loads(call.kwargs["data"]["metadata"])
721 facet = metadata["facets"][0]["name"]
722 calls_by_facet[facet] = call.kwargs
723
724 assert set(calls_by_facet) == {"personal", "work"}
725
726 personal_files = calls_by_facet["personal"]["files"]
727 assert [entry[0] for entry in personal_files] == ["files_0_0", "files_0_1"]
728 personal_metadata = json.loads(calls_by_facet["personal"]["data"]["metadata"])
729 assert personal_metadata == {
730 "facets": [
731 {
732 "name": "personal",
733 "files": [
734 {"path": "facet.json", "type": "facet_json"},
735 {"path": "news/20260413.md", "type": "news"},
736 ],
737 }
738 ]
739 }
740
741 work_files = calls_by_facet["work"]["files"]
742 assert [entry[0] for entry in work_files] == [
743 "files_0_0",
744 "files_0_1",
745 "files_0_2",
746 "files_0_3",
747 ]
748 work_metadata = json.loads(calls_by_facet["work"]["data"]["metadata"])
749 assert work_metadata == {
750 "facets": [
751 {
752 "name": "work",
753 "files": [
754 {
755 "path": "entities/20260413.jsonl",
756 "type": "detected_entities",
757 },
758 {
759 "path": "entities/alice/entity.json",
760 "type": "entity_relationship",
761 },
762 {
763 "path": "entities/alice/observations.jsonl",
764 "type": "entity_observations",
765 },
766 {"path": "facet.json", "type": "facet_json"},
767 ],
768 }
769 ]
770 }
771
772 def test_delta_mixed(self, tmp_path, monkeypatch):
773 from solstone.observe.export import export_facets
774
775 facets_dir = _setup_facets(tmp_path)
776 _set_journal_override(monkeypatch, tmp_path)
777
778 work_dir = facets_dir / "work"
779 personal_dir = facets_dir / "personal"
780 manifest_data = {
781 "received": {
782 "work/facet.json": _facet_file_hash(work_dir / "facet.json"),
783 "work/entities/alice/entity.json": _facet_file_hash(
784 work_dir / "entities" / "alice" / "entity.json"
785 ),
786 "work/entities/alice/observations.jsonl": "stale-hash",
787 "personal/facet.json": _facet_file_hash(personal_dir / "facet.json"),
788 "personal/news/20260413.md": _facet_file_hash(
789 personal_dir / "news" / "20260413.md"
790 ),
791 }
792 }
793 post_json = {"created": 0, "merged": 1, "skipped": 0, "staged": 0, "errors": []}
794 mock_session = _make_session(manifest_data=manifest_data, post_json=post_json)
795
796 with patch(
797 "solstone.observe.export.requests.Session", return_value=mock_session
798 ):
799 export_facets("https://example.com", "test-key", dry_run=False)
800
801 assert mock_session.post.call_count == 1
802 metadata = json.loads(mock_session.post.call_args.kwargs["data"]["metadata"])
803 assert metadata["facets"][0]["name"] == "work"
804 assert metadata["facets"][0]["files"] == [
805 {"path": "entities/20260413.jsonl", "type": "detected_entities"},
806 {
807 "path": "entities/alice/observations.jsonl",
808 "type": "entity_observations",
809 },
810 ]
811
812 def test_dry_run(self, tmp_path, monkeypatch, capsys):
813 from solstone.observe.export import export_facets
814
815 _setup_facets(tmp_path)
816 _set_journal_override(monkeypatch, tmp_path)
817
818 mock_session = _make_session(manifest_data={"received": {}})
819
820 with patch(
821 "solstone.observe.export.requests.Session", return_value=mock_session
822 ):
823 export_facets("https://example.com", "test-key", dry_run=True)
824
825 assert mock_session.post.call_count == 0
826 output = capsys.readouterr().out
827 assert "personal: 2 new, 0 changed, 0 unchanged" in output
828 assert "work: 4 new, 0 changed, 0 unchanged" in output
829 assert (
830 "Dry run: 6 new files, 0 changed, 0 unchanged across 2 facet(s)" in output
831 )
832
833 def test_idempotent(self, tmp_path, monkeypatch, capsys):
834 from solstone.observe.export import export_facets
835
836 facets_dir = _setup_facets(tmp_path)
837 _set_journal_override(monkeypatch, tmp_path)
838
839 manifest_received = {}
840 for facet_dir in sorted((facets_dir).iterdir()):
841 if not facet_dir.is_dir():
842 continue
843 for file_path in sorted(facet_dir.rglob("*")):
844 if file_path.is_file():
845 rel_path = file_path.relative_to(facet_dir).as_posix()
846 manifest_received[f"{facet_dir.name}/{rel_path}"] = (
847 _facet_file_hash(file_path)
848 )
849 mock_session = _make_session(manifest_data={"received": manifest_received})
850
851 with patch(
852 "solstone.observe.export.requests.Session", return_value=mock_session
853 ):
854 export_facets("https://example.com", "test-key", dry_run=False)
855
856 assert mock_session.post.call_count == 0
857 assert "up to date" in capsys.readouterr().out
858
859 def test_error_isolation(self, tmp_path, monkeypatch, capsys):
860 from solstone.observe.export import export_facets
861
862 _setup_facets(tmp_path)
863 _set_journal_override(monkeypatch, tmp_path)
864
865 mock_session = _make_session(manifest_data={"received": {}})
866 first = MagicMock(status_code=400, text="bad request")
867 second = MagicMock(status_code=200, text="ok")
868 second.json.return_value = {
869 "created": 1,
870 "merged": 0,
871 "skipped": 0,
872 "staged": 0,
873 "errors": [],
874 }
875 mock_session.post.side_effect = [first, second]
876
877 with patch(
878 "solstone.observe.export.requests.Session", return_value=mock_session
879 ):
880 export_facets("https://example.com", "test-key", dry_run=False)
881
882 assert mock_session.post.call_count == 2
883 output = capsys.readouterr().out
884 assert "1 sent" in output
885 assert "1 failed" in output
886
887 def test_new_facet_vs_changed(self, tmp_path, monkeypatch, capsys):
888 from solstone.observe.export import export_facets
889
890 facets_dir = _setup_facets(tmp_path)
891 _set_journal_override(monkeypatch, tmp_path)
892
893 work_dir = facets_dir / "work"
894 manifest_data = {
895 "received": {
896 f"work/{file_path.relative_to(work_dir).as_posix()}": "stale-hash"
897 for file_path in sorted(work_dir.rglob("*"))
898 if file_path.is_file()
899 }
900 }
901 mock_session = _make_session(manifest_data=manifest_data)
902
903 with patch(
904 "solstone.observe.export.requests.Session", return_value=mock_session
905 ):
906 export_facets("https://example.com", "test-key", dry_run=True)
907
908 assert mock_session.post.call_count == 0
909 output = capsys.readouterr().out
910 assert "personal: 2 new, 0 changed, 0 unchanged" in output
911 assert "work: 0 new, 4 changed, 0 unchanged" in output
912
913 def test_skips_invalid_facet_names(self, tmp_path, monkeypatch):
914 from solstone.observe.export import export_facets
915
916 _setup_facets(tmp_path)
917 bad_dir = tmp_path / "facets" / "BadName"
918 bad_dir.mkdir(parents=True)
919 (bad_dir / "facet.json").write_text('{"title": "Bad"}', encoding="utf-8")
920 _set_journal_override(monkeypatch, tmp_path)
921
922 post_json = {"created": 1, "merged": 0, "skipped": 0, "staged": 0, "errors": []}
923 mock_session = _make_session(
924 manifest_data={"received": {}}, post_json=post_json
925 )
926
927 with patch(
928 "solstone.observe.export.requests.Session", return_value=mock_session
929 ):
930 export_facets("https://example.com", "test-key", dry_run=False)
931
932 assert mock_session.post.call_count == 2
933 posted_facets = {
934 json.loads(call.kwargs["data"]["metadata"])["facets"][0]["name"]
935 for call in mock_session.post.call_args_list
936 }
937 assert posted_facets == {"personal", "work"}
938
939 def test_skips_events_directory(self, tmp_path, monkeypatch):
940 from solstone.observe.export import export_facets
941
942 _setup_facets(tmp_path)
943 events_dir = tmp_path / "facets" / "work" / "events"
944 events_dir.mkdir(parents=True)
945 (events_dir / "20260413.jsonl").write_text(
946 '{"event": "ignored"}\n', encoding="utf-8"
947 )
948 _set_journal_override(monkeypatch, tmp_path)
949
950 post_json = {"created": 1, "merged": 0, "skipped": 0, "staged": 0, "errors": []}
951 mock_session = _make_session(
952 manifest_data={"received": {}}, post_json=post_json
953 )
954
955 with patch(
956 "solstone.observe.export.requests.Session", return_value=mock_session
957 ):
958 export_facets("https://example.com", "test-key", dry_run=False)
959
960 calls_by_facet = {
961 json.loads(call.kwargs["data"]["metadata"])["facets"][0][
962 "name"
963 ]: call.kwargs
964 for call in mock_session.post.call_args_list
965 }
966 work_metadata = json.loads(calls_by_facet["work"]["data"]["metadata"])
967 uploaded_paths = [
968 entry["path"] for entry in work_metadata["facets"][0]["files"]
969 ]
970 assert "events/20260413.jsonl" not in uploaded_paths
971
972 def test_response_errors_reported(self, tmp_path, monkeypatch, capsys):
973 from solstone.observe.export import export_facets
974
975 _setup_facets(tmp_path)
976 _set_journal_override(monkeypatch, tmp_path)
977
978 post_json = {
979 "created": 1,
980 "merged": 0,
981 "skipped": 0,
982 "staged": 0,
983 "errors": [{"facet": "work", "error": "entity merge conflict"}],
984 }
985 mock_session = _make_session(
986 manifest_data={"received": {}}, post_json=post_json
987 )
988
989 with patch(
990 "solstone.observe.export.requests.Session", return_value=mock_session
991 ):
992 export_facets("https://example.com", "test-key", dry_run=False)
993
994 output = capsys.readouterr().out
995 assert "entity merge conflict" in output
996 assert "error" in output.lower()
997
998
999class TestExportImports:
1000 def test_manifest_delta(self, tmp_path, monkeypatch):
1001 from solstone.observe.export import export_imports
1002
1003 imports = _setup_imports(tmp_path)
1004 _set_journal_override(monkeypatch, tmp_path)
1005
1006 manifest_data = {
1007 "received": {"20260101_090000": _import_hash(imports["20260101_090000"])}
1008 }
1009 post_json = {"copied": 1, "staged": 0, "skipped": 0, "errors": []}
1010 mock_session = _make_session(manifest_data=manifest_data, post_json=post_json)
1011
1012 with patch(
1013 "solstone.observe.export.requests.Session", return_value=mock_session
1014 ):
1015 export_imports("https://example.com", "test-key", dry_run=False)
1016
1017 assert mock_session.post.call_count == 1
1018 posted_data = mock_session.post.call_args.kwargs.get(
1019 "json"
1020 ) or mock_session.post.call_args[1].get("json")
1021 posted_ids = [entry["id"] for entry in posted_data["imports"]]
1022 assert posted_ids == ["20260102_100000"]
1023
1024 def test_dry_run(self, tmp_path, monkeypatch, capsys):
1025 from solstone.observe.export import export_imports
1026
1027 _setup_imports(tmp_path)
1028 _set_journal_override(monkeypatch, tmp_path)
1029
1030 mock_session = _make_session(manifest_data={"received": {}})
1031
1032 with patch(
1033 "solstone.observe.export.requests.Session", return_value=mock_session
1034 ):
1035 export_imports("https://example.com", "test-key", dry_run=True)
1036
1037 assert mock_session.post.call_count == 0
1038 output = capsys.readouterr().out
1039 assert "2 new" in output
1040 assert "0 changed" in output
1041
1042 def test_idempotent(self, tmp_path, monkeypatch, capsys):
1043 from solstone.observe.export import export_imports
1044
1045 imports = _setup_imports(tmp_path)
1046 _set_journal_override(monkeypatch, tmp_path)
1047
1048 manifest_data = {
1049 "received": {
1050 import_id: _import_hash(import_data)
1051 for import_id, import_data in imports.items()
1052 }
1053 }
1054 mock_session = _make_session(manifest_data=manifest_data)
1055
1056 with patch(
1057 "solstone.observe.export.requests.Session", return_value=mock_session
1058 ):
1059 export_imports("https://example.com", "test-key", dry_run=False)
1060
1061 assert mock_session.post.call_count == 0
1062 assert "up to date" in capsys.readouterr().out
1063
1064 def test_sync_state_excluded(self, tmp_path, monkeypatch):
1065 from solstone.observe.export import export_imports
1066
1067 _setup_imports(tmp_path)
1068 _set_journal_override(monkeypatch, tmp_path)
1069
1070 mock_session = _make_session(
1071 manifest_data={"received": {}},
1072 post_json={"copied": 2, "staged": 0, "skipped": 0, "errors": []},
1073 )
1074
1075 with patch(
1076 "solstone.observe.export.requests.Session", return_value=mock_session
1077 ):
1078 export_imports("https://example.com", "test-key", dry_run=False)
1079
1080 posted_data = mock_session.post.call_args.kwargs.get(
1081 "json"
1082 ) or mock_session.post.call_args[1].get("json")
1083 posted_ids = {entry["id"] for entry in posted_data["imports"]}
1084 assert "plaud.json" not in posted_ids
1085
1086 def test_source_dir_excluded(self, tmp_path, monkeypatch):
1087 from solstone.observe.export import export_imports
1088
1089 _setup_imports(tmp_path)
1090 _set_journal_override(monkeypatch, tmp_path)
1091
1092 mock_session = _make_session(
1093 manifest_data={"received": {}},
1094 post_json={"copied": 2, "staged": 0, "skipped": 0, "errors": []},
1095 )
1096
1097 with patch(
1098 "solstone.observe.export.requests.Session", return_value=mock_session
1099 ):
1100 export_imports("https://example.com", "test-key", dry_run=False)
1101
1102 posted_data = mock_session.post.call_args.kwargs.get(
1103 "json"
1104 ) or mock_session.post.call_args[1].get("json")
1105 posted_ids = {entry["id"] for entry in posted_data["imports"]}
1106 assert "abcd1234" not in posted_ids
1107
1108
1109class TestExportConfig:
1110 def test_config_export(self, tmp_path, monkeypatch):
1111 from solstone.observe.export import export_config
1112
1113 _setup_config(tmp_path)
1114 _set_journal_override(monkeypatch, tmp_path)
1115
1116 mock_session = _make_session(
1117 manifest_data={},
1118 post_json={"staged": True, "skipped": False, "diff_fields": 3},
1119 )
1120
1121 with patch(
1122 "solstone.observe.export.requests.Session", return_value=mock_session
1123 ):
1124 export_config("https://example.com", "test-key", dry_run=False)
1125
1126 assert mock_session.post.call_count == 1
1127
1128 def test_dry_run(self, tmp_path, monkeypatch, capsys):
1129 from solstone.observe.export import export_config
1130
1131 _setup_config(tmp_path)
1132 _set_journal_override(monkeypatch, tmp_path)
1133
1134 mock_session = _make_session(manifest_data={})
1135
1136 with patch(
1137 "solstone.observe.export.requests.Session", return_value=mock_session
1138 ):
1139 export_config("https://example.com", "test-key", dry_run=True)
1140
1141 assert mock_session.post.call_count == 0
1142 assert "would send snapshot" in capsys.readouterr().out
1143
1144 def test_idempotent(self, tmp_path, monkeypatch, capsys):
1145 from solstone.observe.export import _strip_never_transfer, export_config
1146
1147 config = _setup_config(tmp_path)
1148 _set_journal_override(monkeypatch, tmp_path)
1149
1150 stripped = _strip_never_transfer(config)
1151 content_hash = hashlib.sha256(
1152 json.dumps(stripped, sort_keys=True, ensure_ascii=False).encode()
1153 ).hexdigest()
1154 mock_session = _make_session(manifest_data={"last_hash": content_hash})
1155
1156 with patch(
1157 "solstone.observe.export.requests.Session", return_value=mock_session
1158 ):
1159 export_config("https://example.com", "test-key", dry_run=False)
1160
1161 assert mock_session.post.call_count == 0
1162 assert "up to date" in capsys.readouterr().out
1163
1164 def test_never_transfer_stripped(self, tmp_path, monkeypatch):
1165 from solstone.observe.export import export_config
1166
1167 _setup_config(tmp_path)
1168 _set_journal_override(monkeypatch, tmp_path)
1169
1170 mock_session = _make_session(
1171 manifest_data={},
1172 post_json={"staged": True, "skipped": False, "diff_fields": 3},
1173 )
1174
1175 with patch(
1176 "solstone.observe.export.requests.Session", return_value=mock_session
1177 ):
1178 export_config("https://example.com", "test-key", dry_run=False)
1179
1180 posted_data = mock_session.post.call_args.kwargs.get(
1181 "json"
1182 ) or mock_session.post.call_args[1].get("json")
1183 posted_config = posted_data["config"]
1184 assert posted_config["convey"] == {"allow_network_access": False}
1185 assert "password_hash" not in posted_config["convey"]
1186 assert "secret" not in posted_config["convey"]
1187 assert posted_config["setup"] == {"completed_at": 12345}
1188 assert posted_config["env"] == {"KEY": "val"}