personal memory agent
0
fork

Configure Feed

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

feat(maint): add 005 migration to rewrite sol dream → sol think in schedules.json

Closes the gap left by the dream→think rename: existing journals' config/schedules.json
still has stale `cmd: ["sol","dream","--weekly","-v"]` entries that now fail with
"Unknown command: dream". This one-shot maint task, run by the supervisor's
run_pending_tasks() before Callosum and scheduler start, rewrites cmd[1] from
"dream" to "think" for any schedule entry whose cmd starts with ["sol","dream"].

- Matches only dict entries with a list-typed cmd of length ≥ 2 starting with
["sol","dream"]; preserves all other fields and top-level scalars.
- Atomic write via tempfile.mkstemp + Path.replace, mirroring
think/scheduler.py:register_defaults(). tmp file cleaned up on BaseException.
- No-op on missing file, empty file, malformed JSON, or already-clean config
(exits 0 with an informative skipped_reason summary).
- --dry-run reports the planned rewrite without writing.

+351
+137
apps/sol/maint/005_migrate_dream_to_think_schedules.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Rewrite stale `sol dream` schedule commands to `sol think`.""" 5 + 6 + from __future__ import annotations 7 + 8 + import argparse 9 + import json 10 + import sys 11 + import tempfile 12 + from dataclasses import dataclass 13 + from pathlib import Path 14 + 15 + from think.utils import get_journal, setup_cli 16 + 17 + 18 + @dataclass 19 + class MigrationSummary: 20 + discovered: int = 0 21 + rewritten: int = 0 22 + preserved: int = 0 23 + errors: int = 0 24 + skipped_reason: str | None = None 25 + 26 + 27 + def _is_dream_schedule_cmd(value: object) -> bool: 28 + return ( 29 + isinstance(value, dict) 30 + and isinstance(value.get("cmd"), list) 31 + and len(value["cmd"]) >= 2 32 + and value["cmd"][0] == "sol" 33 + and value["cmd"][1] == "dream" 34 + ) 35 + 36 + 37 + def run_migration(journal_path: Path, *, dry_run: bool) -> MigrationSummary: 38 + summary = MigrationSummary() 39 + schedules_path = journal_path / "config" / "schedules.json" 40 + 41 + if not schedules_path.exists(): 42 + summary.skipped_reason = "no file" 43 + return summary 44 + 45 + try: 46 + raw_bytes = schedules_path.read_bytes() 47 + except Exception as exc: 48 + summary.errors += 1 49 + print(f"[ERROR] read failed: {schedules_path}: {exc}") 50 + return summary 51 + 52 + if not raw_bytes.strip(): 53 + summary.skipped_reason = "empty file" 54 + return summary 55 + 56 + try: 57 + raw = json.loads(raw_bytes) 58 + except json.JSONDecodeError: 59 + summary.skipped_reason = "unparseable" 60 + return summary 61 + 62 + if not isinstance(raw, dict): 63 + summary.skipped_reason = "unparseable" 64 + return summary 65 + 66 + for name, value in raw.items(): 67 + if _is_dream_schedule_cmd(value): 68 + old_cmd = value["cmd"][:] 69 + new_cmd = old_cmd[:] 70 + new_cmd[1] = "think" 71 + value["cmd"] = new_cmd 72 + summary.discovered += 1 73 + summary.rewritten += 1 74 + print( 75 + f"{'[DRY-RUN] ' if dry_run else ''}rewrite {name}: {old_cmd!r} -> {new_cmd!r}" 76 + ) 77 + else: 78 + summary.preserved += 1 79 + 80 + if summary.discovered == 0: 81 + return summary 82 + 83 + if dry_run: 84 + return summary 85 + 86 + try: 87 + config_dir = schedules_path.parent 88 + # Atomic write 89 + fd, tmp_path = tempfile.mkstemp( 90 + dir=config_dir, suffix=".tmp", prefix=".schedules_" 91 + ) 92 + tmp_file = Path(tmp_path) 93 + try: 94 + with open(fd, "w", encoding="utf-8") as f: 95 + json.dump(raw, f, indent=2) 96 + tmp_file.replace(schedules_path) 97 + except BaseException: 98 + tmp_file.unlink(missing_ok=True) 99 + raise 100 + except Exception as exc: 101 + summary.errors += 1 102 + print(f"[ERROR] write failed: {schedules_path}: {exc}") 103 + 104 + return summary 105 + 106 + 107 + def _print_summary(summary: MigrationSummary) -> None: 108 + print("Summary") 109 + print(f" discovered: {summary.discovered}") 110 + print(f" rewritten: {summary.rewritten}") 111 + print(f" preserved: {summary.preserved}") 112 + print(f" errors: {summary.errors}") 113 + if summary.skipped_reason is not None: 114 + print(f" skipped: {summary.skipped_reason}") 115 + 116 + 117 + def main() -> None: 118 + parser = argparse.ArgumentParser( 119 + description="Rewrite stale sol dream schedule commands to sol think." 120 + ) 121 + parser.add_argument( 122 + "--dry-run", 123 + action="store_true", 124 + help="Preview planned renames without writing files.", 125 + ) 126 + args = setup_cli(parser) 127 + 128 + journal_path = Path(get_journal()) 129 + summary = run_migration(journal_path, dry_run=args.dry_run) 130 + 131 + _print_summary(summary) 132 + if summary.errors: 133 + sys.exit(1) 134 + 135 + 136 + if __name__ == "__main__": 137 + main()
+214
tests/test_maint_005_migrate_dream_to_think_schedules.py
··· 1 + import importlib 2 + import json 3 + from pathlib import Path 4 + 5 + mod = importlib.import_module("apps.sol.maint.005_migrate_dream_to_think_schedules") 6 + DREAM = "dream" 7 + 8 + 9 + def _write_schedules(journal: Path, data: object) -> Path: 10 + config_dir = journal / "config" 11 + config_dir.mkdir(parents=True, exist_ok=True) 12 + schedules_path = config_dir / "schedules.json" 13 + schedules_path.write_text(json.dumps(data, indent=2), encoding="utf-8") 14 + return schedules_path 15 + 16 + 17 + def test_happy_path_rewrites_weekly_agents(tmp_path): 18 + schedules_path = _write_schedules( 19 + tmp_path, 20 + { 21 + "daily_time": "03:17", 22 + "weekly-agents": { 23 + "cmd": ["sol", DREAM, "--weekly", "-v"], 24 + "every": "weekly", 25 + "enabled": True, 26 + }, 27 + }, 28 + ) 29 + 30 + summary = mod.run_migration(tmp_path, dry_run=False) 31 + 32 + assert summary.discovered == 1 33 + assert summary.rewritten == 1 34 + assert summary.preserved == 1 35 + assert summary.errors == 0 36 + data = json.loads(schedules_path.read_text(encoding="utf-8")) 37 + assert data["daily_time"] == "03:17" 38 + assert data["weekly-agents"]["cmd"] == ["sol", "think", "--weekly", "-v"] 39 + assert data["weekly-agents"]["every"] == "weekly" 40 + assert data["weekly-agents"]["enabled"] is True 41 + 42 + 43 + def test_custom_dream_entry_is_rewritten(tmp_path): 44 + schedules_path = _write_schedules( 45 + tmp_path, 46 + { 47 + "my-custom": { 48 + "cmd": ["sol", DREAM, "--segments"], 49 + "every": "daily", 50 + "enabled": True, 51 + } 52 + }, 53 + ) 54 + 55 + summary = mod.run_migration(tmp_path, dry_run=False) 56 + 57 + assert summary.discovered == 1 58 + assert summary.rewritten == 1 59 + assert summary.preserved == 0 60 + assert summary.errors == 0 61 + data = json.loads(schedules_path.read_text(encoding="utf-8")) 62 + assert data["my-custom"] == { 63 + "cmd": ["sol", "think", "--segments"], 64 + "every": "daily", 65 + "enabled": True, 66 + } 67 + 68 + 69 + def test_non_dream_entries_preserved_byte_for_byte(tmp_path): 70 + initial = { 71 + "daily_time": "03:17", 72 + "sync:plaud": { 73 + "cmd": ["sol", "import", "--sync", "plaud", "--save"], 74 + "every": "hourly", 75 + "enabled": True, 76 + }, 77 + "heartbeat": { 78 + "cmd": ["sol", "heartbeat"], 79 + "every": "daily", 80 + "enabled": True, 81 + }, 82 + "weekly-agents": { 83 + "cmd": ["sol", DREAM, "--weekly", "-v"], 84 + "every": "weekly", 85 + "enabled": True, 86 + }, 87 + } 88 + schedules_path = _write_schedules(tmp_path, initial) 89 + 90 + summary = mod.run_migration(tmp_path, dry_run=False) 91 + 92 + assert summary.discovered == 1 93 + assert summary.rewritten == 1 94 + assert summary.preserved == 3 95 + assert summary.errors == 0 96 + data = json.loads(schedules_path.read_text(encoding="utf-8")) 97 + assert data["daily_time"] == initial["daily_time"] 98 + assert data["sync:plaud"] == initial["sync:plaud"] 99 + assert data["heartbeat"] == initial["heartbeat"] 100 + assert data["weekly-agents"]["cmd"] == ["sol", "think", "--weekly", "-v"] 101 + 102 + 103 + def test_idempotent_rerun(tmp_path): 104 + initial = { 105 + "weekly-agents": { 106 + "cmd": ["sol", "think", "--weekly", "-v"], 107 + "every": "weekly", 108 + "enabled": True, 109 + } 110 + } 111 + schedules_path = _write_schedules(tmp_path, initial) 112 + before_bytes = schedules_path.read_bytes() 113 + before_mtime_ns = schedules_path.stat().st_mtime_ns 114 + 115 + summary = mod.run_migration(tmp_path, dry_run=False) 116 + 117 + assert summary.discovered == 0 118 + assert summary.rewritten == 0 119 + assert summary.preserved == 1 120 + assert summary.errors == 0 121 + assert summary.skipped_reason is None 122 + assert schedules_path.read_bytes() == before_bytes 123 + assert json.loads(schedules_path.read_text(encoding="utf-8")) == initial 124 + assert schedules_path.stat().st_mtime_ns == before_mtime_ns 125 + 126 + 127 + def test_missing_file(tmp_path): 128 + config_dir = tmp_path / "config" 129 + config_dir.mkdir(parents=True, exist_ok=True) 130 + 131 + summary = mod.run_migration(tmp_path, dry_run=False) 132 + 133 + assert summary.skipped_reason == "no file" 134 + assert summary.errors == 0 135 + assert summary.discovered == 0 136 + assert not (config_dir / "schedules.json").exists() 137 + 138 + 139 + def test_empty_file(tmp_path): 140 + config_dir = tmp_path / "config" 141 + config_dir.mkdir(parents=True, exist_ok=True) 142 + schedules_path = config_dir / "schedules.json" 143 + schedules_path.write_text("", encoding="utf-8") 144 + 145 + summary = mod.run_migration(tmp_path, dry_run=False) 146 + 147 + assert summary.skipped_reason == "empty file" 148 + assert summary.errors == 0 149 + assert summary.discovered == 0 150 + assert schedules_path.read_text(encoding="utf-8") == "" 151 + 152 + 153 + def test_malformed_json(tmp_path): 154 + config_dir = tmp_path / "config" 155 + config_dir.mkdir(parents=True, exist_ok=True) 156 + schedules_path = config_dir / "schedules.json" 157 + schedules_path.write_text("{not json", encoding="utf-8") 158 + 159 + summary = mod.run_migration(tmp_path, dry_run=False) 160 + 161 + assert summary.skipped_reason == "unparseable" 162 + assert summary.errors == 0 163 + assert summary.discovered == 0 164 + assert schedules_path.read_text(encoding="utf-8") == "{not json" 165 + 166 + 167 + def test_dry_run_does_not_write(tmp_path): 168 + schedules_path = _write_schedules( 169 + tmp_path, 170 + { 171 + "weekly-agents": { 172 + "cmd": ["sol", DREAM, "--weekly", "-v"], 173 + "every": "weekly", 174 + "enabled": True, 175 + } 176 + }, 177 + ) 178 + before_bytes = schedules_path.read_bytes() 179 + config_dir = tmp_path / "config" 180 + 181 + summary = mod.run_migration(tmp_path, dry_run=True) 182 + 183 + assert summary.discovered == 1 184 + assert summary.rewritten == 1 185 + assert summary.preserved == 0 186 + assert summary.errors == 0 187 + assert schedules_path.read_bytes() == before_bytes 188 + assert list(config_dir.glob(".schedules_*.tmp")) == [] 189 + 190 + 191 + def test_atomic_write_failure_cleans_up_tmpfile(tmp_path, monkeypatch): 192 + schedules_path = _write_schedules( 193 + tmp_path, 194 + { 195 + "weekly-agents": { 196 + "cmd": ["sol", DREAM, "--weekly", "-v"], 197 + "every": "weekly", 198 + "enabled": True, 199 + } 200 + }, 201 + ) 202 + before_bytes = schedules_path.read_bytes() 203 + config_dir = tmp_path / "config" 204 + 205 + def _boom(self, target): 206 + raise OSError("boom") 207 + 208 + monkeypatch.setattr(mod.Path, "replace", _boom) 209 + 210 + summary = mod.run_migration(tmp_path, dry_run=False) 211 + 212 + assert summary.errors >= 1 213 + assert schedules_path.read_bytes() == before_bytes 214 + assert list(config_dir.glob(".schedules_*.tmp")) == []