# SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc from __future__ import annotations import json import threading from pathlib import Path from typing import Any import pytest from solstone.think import schedule_config from solstone.think.journal_io.errors import MalformedDataError def _use_journal(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) (tmp_path / "config").mkdir(parents=True, exist_ok=True) return tmp_path / "config" / "schedules.json" def _write_schedules(path: Path, data: Any) -> bytes: payload = json.dumps(data, indent=2, ensure_ascii=False) + "\n" path.parent.mkdir(parents=True, exist_ok=True) path.write_text(payload, encoding="utf-8") return path.read_bytes() def _read_schedules(path: Path) -> dict[str, Any]: return json.loads(path.read_text(encoding="utf-8")) def test_set_schedule_metadata_writes_daily_time_and_preserves_entries( tmp_path, monkeypatch ): path = _use_journal(tmp_path, monkeypatch) _write_schedules( path, {"sync:plaud": {"cmd": ["sol", "import"], "every": "hourly"}}, ) schedule_config.set_schedule_metadata({"daily_time": "03:00"}) raw = _read_schedules(path) assert raw["daily_time"] == "03:00" assert raw["sync:plaud"] == {"cmd": ["sol", "import"], "every": "hourly"} def test_set_schedule_entries_preserves_metadata_and_unrelated_entries( tmp_path, monkeypatch ): path = _use_journal(tmp_path, monkeypatch) _write_schedules( path, { "daily_time": "03:00", "custom": {"cmd": ["journal", "heartbeat"], "every": "daily"}, "sync:plaud": {"cmd": ["old"], "every": "hourly"}, }, ) schedule_config.set_schedule_entries( {"sync:plaud": {"cmd": ["sol", "import"], "every": "hourly"}} ) raw = _read_schedules(path) assert raw["daily_time"] == "03:00" assert raw["custom"] == {"cmd": ["journal", "heartbeat"], "every": "daily"} assert raw["sync:plaud"] == {"cmd": ["sol", "import"], "every": "hourly"} def test_set_schedule_entries_applies_multiple_entries_in_one_call( tmp_path, monkeypatch ): path = _use_journal(tmp_path, monkeypatch) schedule_config.set_schedule_entries( { "one": {"cmd": ["journal", "heartbeat"], "every": "daily"}, "two": {"cmd": ["journal", "brain", "refresh"], "every": "weekly"}, } ) assert _read_schedules(path) == { "one": {"cmd": ["journal", "heartbeat"], "every": "daily"}, "two": {"cmd": ["journal", "brain", "refresh"], "every": "weekly"}, } def test_mutation_preserves_schema_invalid_unrelated_entry_and_load_config_forgives( tmp_path, monkeypatch ): path = _use_journal(tmp_path, monkeypatch) _write_schedules( path, { "daily_time": "03:00", "good": {"cmd": ["journal", "heartbeat"], "every": "daily"}, "badentry": {"cmd": "notalist", "every": "nope"}, }, ) schedule_config.set_schedule_metadata({"weekly_day": "sunday"}) raw = _read_schedules(path) assert raw == { "daily_time": "03:00", "good": {"cmd": ["journal", "heartbeat"], "every": "daily"}, "badentry": {"cmd": "notalist", "every": "nope"}, "weekly_day": "sunday", } import solstone.think.scheduler as scheduler entries = scheduler.load_config() assert entries == {"good": {"cmd": ["journal", "heartbeat"], "every": "daily"}} assert scheduler._daily_time == "03:00" assert scheduler._weekly_day == "sunday" def test_write_failure_leaves_bytes_intact_and_no_temp_file(tmp_path, monkeypatch): path = _use_journal(tmp_path, monkeypatch) before = _write_schedules( path, {"existing": {"cmd": ["journal", "heartbeat"], "every": "daily"}}, ) def _boom(_path, _data): raise OSError("boom") monkeypatch.setattr(schedule_config, "atomic_replace", _boom) with pytest.raises(OSError): schedule_config.set_schedule_entries( {"new": {"cmd": ["journal", "brain", "refresh"], "every": "daily"}} ) assert path.read_bytes() == before assert list(path.parent.glob(".tmp_*")) == [] @pytest.mark.parametrize("payload", ["{ not json", "[]"]) def test_invalid_file_fail_visible_and_preserves_bytes(tmp_path, monkeypatch, payload): path = _use_journal(tmp_path, monkeypatch) path.write_text(payload, encoding="utf-8") before = path.read_bytes() with pytest.raises(MalformedDataError): schedule_config.set_schedule_entries( {"new": {"cmd": ["journal", "heartbeat"], "every": "daily"}} ) assert path.read_bytes() == before def test_concurrent_read_modify_write_preserves_both_updates(tmp_path, monkeypatch): path = _use_journal(tmp_path, monkeypatch) barrier = threading.Barrier(2) errors: list[BaseException] = [] def _set_metadata(): try: barrier.wait() schedule_config.set_schedule_metadata({"daily_time": "03:00"}) except BaseException as exc: errors.append(exc) def _set_entry(): try: barrier.wait() schedule_config.set_schedule_entries( {"sync:plaud": {"cmd": ["sol", "import"], "every": "hourly"}} ) except BaseException as exc: errors.append(exc) threads = [ threading.Thread(target=_set_metadata), threading.Thread(target=_set_entry), ] for thread in threads: thread.start() for thread in threads: thread.join() assert errors == [] raw = _read_schedules(path) assert raw["daily_time"] == "03:00" assert raw["sync:plaud"] == {"cmd": ["sol", "import"], "every": "hourly"} def test_reserved_key_collision_guards(tmp_path, monkeypatch): _use_journal(tmp_path, monkeypatch) with pytest.raises(ValueError): schedule_config.set_schedule_entries( {"daily_time": {"cmd": ["journal", "heartbeat"], "every": "daily"}} ) with pytest.raises(ValueError): schedule_config.remove_schedule_entry("weekly_day") def test_remove_schedule_entry_deletes_present_and_absent_is_noop( tmp_path, monkeypatch ): path = _use_journal(tmp_path, monkeypatch) _write_schedules( path, { "daily_time": "03:00", "remove-me": {"cmd": ["journal", "heartbeat"], "every": "daily"}, "keep-me": {"cmd": ["journal", "brain", "refresh"], "every": "daily"}, }, ) schedule_config.remove_schedule_entry("remove-me") raw = _read_schedules(path) assert "remove-me" not in raw assert raw["daily_time"] == "03:00" assert raw["keep-me"] == { "cmd": ["journal", "brain", "refresh"], "every": "daily", } before = path.read_bytes() schedule_config.remove_schedule_entry("absent") assert path.read_bytes() == before def test_remove_absent_entry_does_not_create_file(tmp_path, monkeypatch): path = _use_journal(tmp_path, monkeypatch) schedule_config.remove_schedule_entry("absent") assert not path.exists()