personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add facet export to sol export CLI

Wire export_facets() into observe/export.py with per-file SHA256 delta
against the receiver manifest, one multipart POST per facet for error
isolation, and --dry-run support. Replaces the "not yet implemented"
stub for --only facets and includes facets in the default export
sequence after entities.

+551 -23
+257 -3
observe/export.py
··· 4 4 """Export journal data to a remote solstone instance. 5 5 6 6 Usage: 7 - sol export --to HOST --key KEY [--only segments] [--dry-run] [--day YYYYMMDD] 7 + sol export --to HOST --key KEY [--only TYPE] [--dry-run] [--day YYYYMMDD] 8 8 """ 9 9 10 10 from __future__ import annotations ··· 13 13 import hashlib 14 14 import json 15 15 import logging 16 + import re 16 17 import sys 17 18 import time 18 - from pathlib import Path 19 + from pathlib import Path, PurePosixPath 19 20 from typing import Any 20 21 21 22 import requests ··· 32 33 logger = logging.getLogger(__name__) 33 34 34 35 UPLOAD_TIMEOUT = 300 36 + _FACET_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$") 37 + _DAY_RE = re.compile(r"^\d{8}$") 38 + _DAY_JSONL_RE = re.compile(r"^\d{8}\.jsonl$") 39 + _DAY_MD_RE = re.compile(r"^\d{8}\.md$") 35 40 36 41 37 42 def _query_manifest( ··· 143 148 time.sleep(delay) 144 149 145 150 return ("error", 0) 151 + 152 + 153 + def _classify_facet_file(relative: PurePosixPath) -> str | None: 154 + """Classify a facet file by its relative path, returning the ingest type or None.""" 155 + parts = relative.parts 156 + 157 + if parts == ("facet.json",): 158 + return "facet_json" 159 + 160 + if len(parts) >= 2 and parts[0] == "entities": 161 + if len(parts) == 3 and parts[2] == "entity.json": 162 + return "entity_relationship" 163 + if len(parts) == 3 and parts[2] == "observations.jsonl": 164 + return "entity_observations" 165 + if len(parts) == 2 and _DAY_JSONL_RE.match(parts[1]): 166 + return "detected_entities" 167 + return None 168 + 169 + if len(parts) >= 2 and parts[0] == "activities": 170 + if parts == ("activities", "activities.jsonl"): 171 + return "activity_config" 172 + if len(parts) == 2 and _DAY_JSONL_RE.match(parts[1]): 173 + return "activity_records" 174 + if len(parts) >= 4 and _DAY_RE.match(parts[1]): 175 + return "activity_output" 176 + return None 177 + 178 + if len(parts) == 2 and parts[0] == "todos" and _DAY_JSONL_RE.match(parts[1]): 179 + return "todos" 180 + 181 + if len(parts) == 2 and parts[0] == "calendar" and _DAY_JSONL_RE.match(parts[1]): 182 + return "calendar" 183 + 184 + if len(parts) == 2 and parts[0] == "news" and _DAY_MD_RE.match(parts[1]): 185 + return "news" 186 + 187 + if len(parts) == 2 and parts[0] == "logs" and _DAY_JSONL_RE.match(parts[1]): 188 + return "logs" 189 + 190 + return None 146 191 147 192 148 193 def export_segments(base_url: str, key: str, days: list[str], dry_run: bool) -> None: ··· 364 409 session.close() 365 410 366 411 412 + def export_facets(base_url: str, key: str, dry_run: bool) -> None: 413 + session = requests.Session() 414 + session.headers["Authorization"] = f"Bearer {key}" 415 + 416 + try: 417 + try: 418 + remote_manifest = _query_manifest(session, base_url, key, area="facets") 419 + except requests.ConnectionError: 420 + print(f"Connection failed: could not reach {base_url}") 421 + return 422 + except ValueError as e: 423 + print(str(e)) 424 + return 425 + 426 + received = remote_manifest.get("received", {}) 427 + 428 + facets_dir = Path(get_journal()) / "facets" 429 + if not facets_dir.is_dir(): 430 + print("No facets found to export") 431 + return 432 + 433 + facet_names = sorted( 434 + d.name 435 + for d in facets_dir.iterdir() 436 + if d.is_dir() and _FACET_NAME_RE.match(d.name) and (d / "facet.json").is_file() 437 + ) 438 + if not facet_names: 439 + print("No facets found to export") 440 + return 441 + 442 + total_new = 0 443 + total_changed = 0 444 + total_unchanged = 0 445 + total_facets_sent = 0 446 + total_facets_failed = 0 447 + total_facets_skipped = 0 448 + total_errors = 0 449 + 450 + key_prefix = key[:8] 451 + url = f"{base_url}/app/import/journal/{key_prefix}/ingest/facets" 452 + 453 + for facet_name in facet_names: 454 + facet_path = facets_dir / facet_name 455 + 456 + classified_files = [] 457 + for abs_path in sorted(facet_path.rglob("*"), key=lambda path: path.as_posix()): 458 + if not abs_path.is_file(): 459 + continue 460 + relative = PurePosixPath(abs_path.relative_to(facet_path)) 461 + file_type = _classify_facet_file(relative) 462 + if file_type is None: 463 + continue 464 + classified_files.append((str(relative), file_type, abs_path)) 465 + 466 + if not classified_files: 467 + continue 468 + 469 + new_files = [] 470 + changed_files = [] 471 + unchanged_count = 0 472 + 473 + for rel_path, file_type, abs_path in classified_files: 474 + content_hash = hashlib.sha256(abs_path.read_bytes()).hexdigest() 475 + manifest_key = f"{facet_name}/{rel_path}" 476 + remote_hash = received.get(manifest_key) 477 + if remote_hash == content_hash: 478 + unchanged_count += 1 479 + elif remote_hash is not None: 480 + changed_files.append((rel_path, file_type, abs_path)) 481 + else: 482 + new_files.append((rel_path, file_type, abs_path)) 483 + 484 + to_send = new_files + changed_files 485 + total_new += len(new_files) 486 + total_changed += len(changed_files) 487 + total_unchanged += unchanged_count 488 + 489 + if not to_send: 490 + total_facets_skipped += 1 491 + continue 492 + 493 + if dry_run: 494 + print( 495 + f" {facet_name}: {len(new_files)} new, " 496 + f"{len(changed_files)} changed, {unchanged_count} unchanged" 497 + ) 498 + total_facets_sent += 1 499 + continue 500 + 501 + metadata = { 502 + "facets": [ 503 + { 504 + "name": facet_name, 505 + "files": [ 506 + {"path": rel_path, "type": file_type} 507 + for rel_path, file_type, _ in to_send 508 + ], 509 + } 510 + ] 511 + } 512 + 513 + for attempt, delay in enumerate(RETRY_BACKOFF): 514 + file_handles = [] 515 + files_data = [] 516 + try: 517 + for file_idx, (_, _, abs_path) in enumerate(to_send): 518 + fh = open(abs_path, "rb") 519 + file_handles.append(fh) 520 + files_data.append( 521 + ( 522 + f"files_0_{file_idx}", 523 + (abs_path.name, fh, "application/octet-stream"), 524 + ) 525 + ) 526 + 527 + response = session.post( 528 + url, 529 + data={"metadata": json.dumps(metadata)}, 530 + files=files_data, 531 + timeout=UPLOAD_TIMEOUT, 532 + ) 533 + if response.status_code == 200: 534 + result = response.json() 535 + errors = result.get("errors", []) 536 + total_errors += len(errors) 537 + if errors: 538 + for err in errors: 539 + print(f" Error ({facet_name}): {err}") 540 + logger.info( 541 + " [sent] %s: %s created, %s merged, %s staged", 542 + facet_name, 543 + result.get("created", 0), 544 + result.get("merged", 0), 545 + result.get("staged", 0), 546 + ) 547 + total_facets_sent += 1 548 + break 549 + if response.status_code == 401: 550 + print("Authentication failed: invalid or missing API key") 551 + return 552 + if response.status_code == 403: 553 + print("Authentication failed: journal source revoked or disabled") 554 + return 555 + if 500 <= response.status_code <= 599: 556 + logger.warning( 557 + "Facet upload attempt %s failed for %s: %s %s", 558 + attempt + 1, 559 + facet_name, 560 + response.status_code, 561 + response.text, 562 + ) 563 + else: 564 + logger.warning( 565 + "Facet upload rejected for %s: %s %s", 566 + facet_name, 567 + response.status_code, 568 + response.text, 569 + ) 570 + total_facets_failed += 1 571 + break 572 + except (requests.RequestException, OSError) as e: 573 + logger.warning( 574 + "Facet upload attempt %s failed for %s: %s", 575 + attempt + 1, 576 + facet_name, 577 + e, 578 + ) 579 + finally: 580 + for fh in file_handles: 581 + try: 582 + fh.close() 583 + except Exception: 584 + pass 585 + 586 + if attempt < len(RETRY_BACKOFF) - 1: 587 + time.sleep(delay) 588 + else: 589 + logger.warning("Facet upload failed after all retries for %s", facet_name) 590 + total_facets_failed += 1 591 + 592 + if dry_run: 593 + if total_new + total_changed == 0: 594 + print("Nothing to send - remote facets are up to date") 595 + else: 596 + print( 597 + f"\nDry run: {total_new} new files, {total_changed} changed, " 598 + f"{total_unchanged} unchanged across {total_facets_sent} facet(s)" 599 + ) 600 + return 601 + 602 + if total_facets_sent == 0 and total_facets_failed == 0: 603 + print("Nothing to send - remote facets are up to date") 604 + return 605 + 606 + print( 607 + f"\nFacet export complete: {total_facets_sent} sent, " 608 + f"{total_facets_skipped} skipped, {total_facets_failed} failed" 609 + ) 610 + if total_errors: 611 + print(f" {total_errors} error(s)") 612 + finally: 613 + session.close() 614 + 615 + 367 616 def main() -> None: 368 617 parser = argparse.ArgumentParser( 369 618 description="Export journal data to a remote solstone instance" ··· 381 630 parser.add_argument( 382 631 "--only", 383 632 default=None, 384 - help="Export only specific area (segments, entities)", 633 + help="Export only specific area (segments, entities, facets)", 385 634 ) 386 635 parser.add_argument( 387 636 "--dry-run", ··· 401 650 export_entities(base_url, args.key, args.dry_run) 402 651 return 403 652 653 + if args.only == "facets": 654 + export_facets(base_url, args.key, args.dry_run) 655 + return 656 + 404 657 if args.only is not None and args.only != "segments": 405 658 print(f"Export of '{args.only}' is not yet implemented") 406 659 sys.exit(0) ··· 409 662 export_segments(base_url, args.key, days, args.dry_run) 410 663 if args.only is None: 411 664 export_entities(base_url, args.key, args.dry_run) 665 + export_facets(base_url, args.key, args.dry_run)
+294 -20
tests/test_export.py
··· 105 105 ).hexdigest() 106 106 107 107 108 + def _setup_facets(tmp_path): 109 + """Create test facets in journal fixture.""" 110 + facets_dir = tmp_path / "facets" 111 + 112 + work_dir = facets_dir / "work" 113 + work_dir.mkdir(parents=True) 114 + (work_dir / "facet.json").write_text('{"title": "Work"}', encoding="utf-8") 115 + 116 + ent_dir = work_dir / "entities" / "alice" 117 + ent_dir.mkdir(parents=True) 118 + (ent_dir / "entity.json").write_text('{"id": "alice"}', encoding="utf-8") 119 + (ent_dir / "observations.jsonl").write_text('{"text": "obs1"}\n', encoding="utf-8") 120 + 121 + det_dir = work_dir / "entities" 122 + (det_dir / "20260413.jsonl").write_text('{"entity": "test"}\n', encoding="utf-8") 123 + 124 + todo_dir = work_dir / "todos" 125 + todo_dir.mkdir(parents=True) 126 + (todo_dir / "20260413.jsonl").write_text('{"task": "do stuff"}\n', encoding="utf-8") 127 + 128 + personal_dir = facets_dir / "personal" 129 + personal_dir.mkdir(parents=True) 130 + (personal_dir / "facet.json").write_text('{"title": "Personal"}', encoding="utf-8") 131 + 132 + news_dir = personal_dir / "news" 133 + news_dir.mkdir(parents=True) 134 + (news_dir / "20260413.md").write_text("# News\nHello world\n", encoding="utf-8") 135 + 136 + return facets_dir 137 + 138 + 139 + def _facet_file_hash(path: Path) -> str: 140 + return hashlib.sha256(path.read_bytes()).hexdigest() 141 + 142 + 108 143 class TestExportSegments: 109 144 def test_manifest_query_and_delta(self, tmp_path, monkeypatch): 110 145 from observe.export import export_segments ··· 298 333 assert mock_session.post.call_count == 0 299 334 assert "up to date" in capsys.readouterr().out 300 335 301 - def test_only_not_implemented(self, capsys): 302 - from observe.export import main 303 - 304 - mock_args = MagicMock() 305 - mock_args.to = "host" 306 - mock_args.key = "testkey" 307 - mock_args.only = "facets" 308 - mock_args.dry_run = False 309 - mock_args.day = None 310 - 311 - with ( 312 - patch("sys.argv", ["sol export", "--to", "host", "--key", "testkey", "--only", "facets"]), 313 - patch("observe.export.setup_cli", return_value=mock_args), 314 - ): 315 - with pytest.raises(SystemExit) as excinfo: 316 - main() 317 - 318 - assert excinfo.value.code == 0 319 - assert "not yet implemented" in capsys.readouterr().out 320 - 321 336 def test_upload_error_isolation(self, tmp_path, monkeypatch, capsys): 322 337 from observe.export import export_segments 323 338 ··· 547 562 output = capsys.readouterr().out 548 563 assert "Entity bob_smith: invalid type" in output 549 564 assert "1 error" in output 565 + 566 + 567 + class TestExportFacets: 568 + def test_full_export(self, tmp_path, monkeypatch): 569 + from observe.export import export_facets 570 + 571 + _setup_facets(tmp_path) 572 + _set_journal_override(monkeypatch, tmp_path) 573 + 574 + post_json = {"created": 1, "merged": 0, "skipped": 0, "staged": 0, "errors": []} 575 + mock_session = _make_session(manifest_data={"received": {}}, post_json=post_json) 576 + 577 + with patch("observe.export.requests.Session", return_value=mock_session): 578 + export_facets("https://example.com", "test-key", dry_run=False) 579 + 580 + assert mock_session.post.call_count == 2 581 + 582 + calls_by_facet = {} 583 + for call in mock_session.post.call_args_list: 584 + metadata = json.loads(call.kwargs["data"]["metadata"]) 585 + facet = metadata["facets"][0]["name"] 586 + calls_by_facet[facet] = call.kwargs 587 + 588 + assert set(calls_by_facet) == {"personal", "work"} 589 + 590 + personal_files = calls_by_facet["personal"]["files"] 591 + assert [entry[0] for entry in personal_files] == ["files_0_0", "files_0_1"] 592 + personal_metadata = json.loads(calls_by_facet["personal"]["data"]["metadata"]) 593 + assert personal_metadata == { 594 + "facets": [ 595 + { 596 + "name": "personal", 597 + "files": [ 598 + {"path": "facet.json", "type": "facet_json"}, 599 + {"path": "news/20260413.md", "type": "news"}, 600 + ], 601 + } 602 + ] 603 + } 604 + 605 + work_files = calls_by_facet["work"]["files"] 606 + assert [entry[0] for entry in work_files] == [ 607 + "files_0_0", 608 + "files_0_1", 609 + "files_0_2", 610 + "files_0_3", 611 + "files_0_4", 612 + ] 613 + work_metadata = json.loads(calls_by_facet["work"]["data"]["metadata"]) 614 + assert work_metadata == { 615 + "facets": [ 616 + { 617 + "name": "work", 618 + "files": [ 619 + {"path": "entities/20260413.jsonl", "type": "detected_entities"}, 620 + {"path": "entities/alice/entity.json", "type": "entity_relationship"}, 621 + { 622 + "path": "entities/alice/observations.jsonl", 623 + "type": "entity_observations", 624 + }, 625 + {"path": "facet.json", "type": "facet_json"}, 626 + {"path": "todos/20260413.jsonl", "type": "todos"}, 627 + ], 628 + } 629 + ] 630 + } 631 + 632 + def test_delta_mixed(self, tmp_path, monkeypatch): 633 + from observe.export import export_facets 634 + 635 + facets_dir = _setup_facets(tmp_path) 636 + _set_journal_override(monkeypatch, tmp_path) 637 + 638 + work_dir = facets_dir / "work" 639 + personal_dir = facets_dir / "personal" 640 + manifest_data = { 641 + "received": { 642 + "work/facet.json": _facet_file_hash(work_dir / "facet.json"), 643 + "work/entities/alice/entity.json": _facet_file_hash( 644 + work_dir / "entities" / "alice" / "entity.json" 645 + ), 646 + "work/entities/alice/observations.jsonl": "stale-hash", 647 + "work/todos/20260413.jsonl": "stale-hash", 648 + "personal/facet.json": _facet_file_hash(personal_dir / "facet.json"), 649 + "personal/news/20260413.md": _facet_file_hash( 650 + personal_dir / "news" / "20260413.md" 651 + ), 652 + } 653 + } 654 + post_json = {"created": 0, "merged": 1, "skipped": 0, "staged": 0, "errors": []} 655 + mock_session = _make_session(manifest_data=manifest_data, post_json=post_json) 656 + 657 + with patch("observe.export.requests.Session", return_value=mock_session): 658 + export_facets("https://example.com", "test-key", dry_run=False) 659 + 660 + assert mock_session.post.call_count == 1 661 + metadata = json.loads(mock_session.post.call_args.kwargs["data"]["metadata"]) 662 + assert metadata["facets"][0]["name"] == "work" 663 + assert metadata["facets"][0]["files"] == [ 664 + {"path": "entities/20260413.jsonl", "type": "detected_entities"}, 665 + {"path": "entities/alice/observations.jsonl", "type": "entity_observations"}, 666 + {"path": "todos/20260413.jsonl", "type": "todos"}, 667 + ] 668 + 669 + def test_dry_run(self, tmp_path, monkeypatch, capsys): 670 + from observe.export import export_facets 671 + 672 + _setup_facets(tmp_path) 673 + _set_journal_override(monkeypatch, tmp_path) 674 + 675 + mock_session = _make_session(manifest_data={"received": {}}) 676 + 677 + with patch("observe.export.requests.Session", return_value=mock_session): 678 + export_facets("https://example.com", "test-key", dry_run=True) 679 + 680 + assert mock_session.post.call_count == 0 681 + output = capsys.readouterr().out 682 + assert "personal: 2 new, 0 changed, 0 unchanged" in output 683 + assert "work: 5 new, 0 changed, 0 unchanged" in output 684 + assert "Dry run: 7 new files, 0 changed, 0 unchanged across 2 facet(s)" in output 685 + 686 + def test_idempotent(self, tmp_path, monkeypatch, capsys): 687 + from observe.export import export_facets 688 + 689 + facets_dir = _setup_facets(tmp_path) 690 + _set_journal_override(monkeypatch, tmp_path) 691 + 692 + manifest_received = {} 693 + for facet_dir in sorted((facets_dir).iterdir()): 694 + if not facet_dir.is_dir(): 695 + continue 696 + for file_path in sorted(facet_dir.rglob("*")): 697 + if file_path.is_file(): 698 + rel_path = file_path.relative_to(facet_dir).as_posix() 699 + manifest_received[f"{facet_dir.name}/{rel_path}"] = _facet_file_hash(file_path) 700 + mock_session = _make_session(manifest_data={"received": manifest_received}) 701 + 702 + with patch("observe.export.requests.Session", return_value=mock_session): 703 + export_facets("https://example.com", "test-key", dry_run=False) 704 + 705 + assert mock_session.post.call_count == 0 706 + assert "up to date" in capsys.readouterr().out 707 + 708 + def test_error_isolation(self, tmp_path, monkeypatch, capsys): 709 + from observe.export import export_facets 710 + 711 + _setup_facets(tmp_path) 712 + _set_journal_override(monkeypatch, tmp_path) 713 + 714 + mock_session = _make_session(manifest_data={"received": {}}) 715 + first = MagicMock(status_code=400, text="bad request") 716 + second = MagicMock(status_code=200, text="ok") 717 + second.json.return_value = { 718 + "created": 1, 719 + "merged": 0, 720 + "skipped": 0, 721 + "staged": 0, 722 + "errors": [], 723 + } 724 + mock_session.post.side_effect = [first, second] 725 + 726 + with patch("observe.export.requests.Session", return_value=mock_session): 727 + export_facets("https://example.com", "test-key", dry_run=False) 728 + 729 + assert mock_session.post.call_count == 2 730 + output = capsys.readouterr().out 731 + assert "1 sent" in output 732 + assert "1 failed" in output 733 + 734 + def test_new_facet_vs_changed(self, tmp_path, monkeypatch, capsys): 735 + from observe.export import export_facets 736 + 737 + facets_dir = _setup_facets(tmp_path) 738 + _set_journal_override(monkeypatch, tmp_path) 739 + 740 + work_dir = facets_dir / "work" 741 + manifest_data = { 742 + "received": { 743 + f"work/{file_path.relative_to(work_dir).as_posix()}": "stale-hash" 744 + for file_path in sorted(work_dir.rglob("*")) 745 + if file_path.is_file() 746 + } 747 + } 748 + mock_session = _make_session(manifest_data=manifest_data) 749 + 750 + with patch("observe.export.requests.Session", return_value=mock_session): 751 + export_facets("https://example.com", "test-key", dry_run=True) 752 + 753 + assert mock_session.post.call_count == 0 754 + output = capsys.readouterr().out 755 + assert "personal: 2 new, 0 changed, 0 unchanged" in output 756 + assert "work: 0 new, 5 changed, 0 unchanged" in output 757 + 758 + def test_skips_invalid_facet_names(self, tmp_path, monkeypatch): 759 + from observe.export import export_facets 760 + 761 + _setup_facets(tmp_path) 762 + bad_dir = tmp_path / "facets" / "BadName" 763 + bad_dir.mkdir(parents=True) 764 + (bad_dir / "facet.json").write_text('{"title": "Bad"}', encoding="utf-8") 765 + _set_journal_override(monkeypatch, tmp_path) 766 + 767 + post_json = {"created": 1, "merged": 0, "skipped": 0, "staged": 0, "errors": []} 768 + mock_session = _make_session(manifest_data={"received": {}}, post_json=post_json) 769 + 770 + with patch("observe.export.requests.Session", return_value=mock_session): 771 + export_facets("https://example.com", "test-key", dry_run=False) 772 + 773 + assert mock_session.post.call_count == 2 774 + posted_facets = { 775 + json.loads(call.kwargs["data"]["metadata"])["facets"][0]["name"] 776 + for call in mock_session.post.call_args_list 777 + } 778 + assert posted_facets == {"personal", "work"} 779 + 780 + def test_skips_events_directory(self, tmp_path, monkeypatch): 781 + from observe.export import export_facets 782 + 783 + _setup_facets(tmp_path) 784 + events_dir = tmp_path / "facets" / "work" / "events" 785 + events_dir.mkdir(parents=True) 786 + (events_dir / "20260413.jsonl").write_text('{"event": "ignored"}\n', encoding="utf-8") 787 + _set_journal_override(monkeypatch, tmp_path) 788 + 789 + post_json = {"created": 1, "merged": 0, "skipped": 0, "staged": 0, "errors": []} 790 + mock_session = _make_session(manifest_data={"received": {}}, post_json=post_json) 791 + 792 + with patch("observe.export.requests.Session", return_value=mock_session): 793 + export_facets("https://example.com", "test-key", dry_run=False) 794 + 795 + calls_by_facet = { 796 + json.loads(call.kwargs["data"]["metadata"])["facets"][0]["name"]: call.kwargs 797 + for call in mock_session.post.call_args_list 798 + } 799 + work_metadata = json.loads(calls_by_facet["work"]["data"]["metadata"]) 800 + uploaded_paths = [entry["path"] for entry in work_metadata["facets"][0]["files"]] 801 + assert "events/20260413.jsonl" not in uploaded_paths 802 + 803 + def test_response_errors_reported(self, tmp_path, monkeypatch, capsys): 804 + from observe.export import export_facets 805 + 806 + _setup_facets(tmp_path) 807 + _set_journal_override(monkeypatch, tmp_path) 808 + 809 + post_json = { 810 + "created": 1, 811 + "merged": 0, 812 + "skipped": 0, 813 + "staged": 0, 814 + "errors": [{"facet": "work", "error": "entity merge conflict"}], 815 + } 816 + mock_session = _make_session(manifest_data={"received": {}}, post_json=post_json) 817 + 818 + with patch("observe.export.requests.Session", return_value=mock_session): 819 + export_facets("https://example.com", "test-key", dry_run=False) 820 + 821 + output = capsys.readouterr().out 822 + assert "entity merge conflict" in output 823 + assert "error" in output.lower()